Add persistent LoRa OTA speed control and harden timing
Add get/set ota.speed and OTA CLI aliases for factors from 0.05 to 3, defaulting to the existing pace. Persist the setting independently of the OTA workspace and apply it to OTA packet pacing, relays, discovery and adverts while retaining the configured airtime budget. Preserve physical proof/retry allowances, account for both participating radio profiles, keep optional deadlines armed across millis rollover, and exclude intentional pacing from observer stale-packet age. Keep local verification at its normal rate and retain a valid saved speed if backup recovery cannot finish. Add documentation and CI regression coverage. Validation: 1,491 native tests and 26 CLI/integration tests passed. Built ESP32 full repeater, ESP32 full Companion, nRF52 full Companion and STM32 USB Companion successfully, including their memory/size checks.
This commit is contained in:
1 parent
a07f39c6dd
commit
b2f2b5bf02
22 files changed
+1032
-36
No files matched your search
@@ -170,6 +170,13 @@ does not reflect the GoogleTest count -- run the built binary directly
|
||||
suite may need shims **before** the include -- e.g. `test_mqtt_presets` does
|
||||
`#define WITH_MQTT_BRIDGE 1` (the preset table is behind that flag) and
|
||||
`#define PROGMEM` (the embedded CA-cert strings are PROGMEM-qualified).
|
||||
- OTA speed: `python3 -B test/test_ota_speed.py -v` exercises the shared CLI,
|
||||
saved settings, failed writes, and failed backup recovery. Native `test_ota`
|
||||
covers complete transfers with loss, live changes, physical proof allowances,
|
||||
local verification, discovery, and deadlines landing exactly on clock rollover.
|
||||
`test_trace_retry` checks both profile copies, stable dual-profile airtime, and
|
||||
ordinary traffic during OTA pacing; `test_packet_manager` checks that intentional
|
||||
pacing survives stale expiry without erasing prior congestion age.
|
||||
- To add a suite: create `test/test_<name>/test_<name>.cpp` with a `main()`, and
|
||||
add any host-only source it links to the `native` env's `build_src_filter` in
|
||||
`platformio.ini` (header-only code needs no source entry). No other wiring.
|
||||
|
||||
+71
@@ -62,8 +62,12 @@ class DualProfileTestMesh : public RetryCodingRateMesh {
|
||||
using RetryCodingRateMesh::isPacketRadioCurrent;
|
||||
using RetryCodingRateMesh::getTransmitAirtime;
|
||||
using RetryCodingRateMesh::getTransmitProfileMask;
|
||||
using RetryCodingRateMesh::getRetransmitDelay;
|
||||
using RetryCodingRateMesh::getOtaPacketAirtime;
|
||||
using RetryCodingRateMesh::tryParsePacket;
|
||||
bool cross_filter_allows = true;
|
||||
float ota_speed = 1.0f;
|
||||
float getOtaSpeedFactor() const override { return ota_speed; }
|
||||
unsigned cross_filter_calls = 0;
|
||||
bool allowRadioProfileCross(const mesh::Packet*) override {
|
||||
++cross_filter_calls;
|
||||
@@ -100,6 +104,73 @@ class DualProfileTest : public testing::Test {
|
||||
void tick(uint32_t ms = 1) { clock.now += ms; node.loop(); }
|
||||
};
|
||||
|
||||
TEST_F(DualProfileTest, OtaSpeedPacesBothCopiesAndYieldsToOrdinaryTraffic) {
|
||||
node.flood_attempts = 0;
|
||||
node.tempRadioActive = true;
|
||||
node.ota_speed = 0.05f;
|
||||
radio.config.cross = mesh::RadioCrossMode::On;
|
||||
auto* first = queue(PAYLOAD_TYPE_OTA);
|
||||
ASSERT_NE(nullptr, first);
|
||||
tick(); ASSERT_TRUE(radio.sending);
|
||||
radio.complete = true;
|
||||
tick(100); // actual OTA TX airtime: 100 ms; quiet allowance: 1900 ms
|
||||
EXPECT_EQ(radio.transmissions.size(), 1u);
|
||||
auto* normal = queue(PAYLOAD_TYPE_GRP_TXT);
|
||||
ASSERT_NE(nullptr, normal);
|
||||
tick();
|
||||
ASSERT_TRUE(radio.sending); // the OTA copy yields to normal messages
|
||||
EXPECT_EQ(radio.transmissions.size(), 2u);
|
||||
radio.complete = true; tick(100);
|
||||
tick(); // the normal dispatcher's next-TX deadline opens on the following loop
|
||||
ASSERT_TRUE(radio.sending);
|
||||
radio.complete = true; tick(100);
|
||||
EXPECT_EQ(radio.transmissions.size(), 3u); // both normal-message profiles sent
|
||||
tick(1000);
|
||||
EXPECT_EQ(radio.transmissions.size(), 3u);
|
||||
node.ota_speed = 1.0f;
|
||||
tick(101); // discard the old slow wait without losing the secondary copy
|
||||
EXPECT_EQ(radio.transmissions.size(), 4u);
|
||||
EXPECT_EQ(radio.transmissions.back(), 1u);
|
||||
radio.complete = true; tick(100);
|
||||
EXPECT_EQ(manager.getFreeCount(), 40);
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, OtaSpeedScalesRelayWindowButLeavesOrdinaryRelaysAlone) {
|
||||
rng.value = 2;
|
||||
auto ota_packet = makeFloodPacket(PAYLOAD_TYPE_OTA);
|
||||
ota_packet.payload[0] = mesh::ota::OTA_DATA;
|
||||
auto ordinary_packet = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT);
|
||||
const auto ordinary_delay = node.getRetransmitDelay(&ordinary_packet);
|
||||
const auto normal = node.otaRelayDelay(&ota_packet);
|
||||
ASSERT_GT(normal, 0u);
|
||||
node.ota_speed = 0.5f;
|
||||
EXPECT_EQ(node.otaRelayDelay(&ota_packet), normal * 2);
|
||||
EXPECT_EQ(node.getRetransmitDelay(&ordinary_packet), ordinary_delay);
|
||||
node.ota_speed = 3.0f;
|
||||
EXPECT_EQ(node.otaRelayDelay(&ota_packet), (normal + 2) / 3);
|
||||
EXPECT_EQ(node.getRetransmitDelay(&ordinary_packet), ordinary_delay);
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, OtaAirtimeFollowsParticipatingProfilesInsteadOfScannerVisit) {
|
||||
radio.distinguish_airtime = true; // primary 900 ms, secondary 70 ms
|
||||
radio.config.secondary_temporary = true;
|
||||
radio.config.cross = mesh::RadioCrossMode::Off;
|
||||
EXPECT_EQ(node.getOtaPacketAirtime(), 70u);
|
||||
radio.selected = 1;
|
||||
radio.estimated_airtime = 12345;
|
||||
EXPECT_EQ(node.getOtaPacketAirtime(), 70u);
|
||||
radio.config.reply_tx = mesh::RADIO_TX_BOTH;
|
||||
EXPECT_EQ(node.getOtaPacketAirtime(), 970u);
|
||||
radio.config.secondary_temporary = false;
|
||||
radio.config.primary_temporary = true;
|
||||
radio.config.secondary.mode = mesh::RadioProfileMode::Rx;
|
||||
EXPECT_EQ(node.getOtaPacketAirtime(), 900u);
|
||||
radio.config.reply_force = true;
|
||||
EXPECT_EQ(node.getOtaPacketAirtime(), 970u);
|
||||
radio.config.secondary.mode = mesh::RadioProfileMode::Off;
|
||||
EXPECT_EQ(node.getOtaPacketAirtime(), 900u);
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, CrossFilterKeepsPrimaryTransmissionAndAvoidsCopyAllocation) {
|
||||
node.cross_filter_allows = false;
|
||||
ASSERT_NE(nullptr, queue());
|
||||
|
||||
@@ -4676,6 +4676,296 @@ TEST(OtaWarmStart, LeafDiffAuthenticatesAndFindsDifferingBlocks) {
|
||||
EXPECT_FALSE(miss[0]); EXPECT_FALSE(miss[2]); EXPECT_FALSE(miss[4]);
|
||||
}
|
||||
|
||||
TEST(OtaSpeed, BoundsScalingAndLongIntervals) {
|
||||
EXPECT_TRUE(validSpeed(0.05f));
|
||||
EXPECT_TRUE(validSpeed(3.0f));
|
||||
EXPECT_FALSE(validSpeed(0.049f));
|
||||
EXPECT_FALSE(validSpeed(3.001f));
|
||||
EXPECT_FALSE(validSpeed(NAN));
|
||||
EXPECT_FALSE(validSpeed(INFINITY));
|
||||
for (uint32_t delay : {0u, 1u, 100u, 1000u, 86400000u}) {
|
||||
EXPECT_EQ(scaleDelay(delay, 1.0f), delay);
|
||||
}
|
||||
EXPECT_EQ(scaleDelay(1000, 0.05f), 20000u);
|
||||
EXPECT_EQ(scaleDelay(1000, 0.5f), 2000u);
|
||||
EXPECT_EQ(scaleDelay(1000, 3.0f), 334u);
|
||||
EXPECT_EQ(scaleDelay(UINT32_MAX, 0.05f), (uint32_t)INT32_MAX);
|
||||
EXPECT_EQ(relayDelay(2000000, 0.05f), 0x00ffffffU);
|
||||
EXPECT_EQ(relayDelay(100, 1.0f), 100u);
|
||||
EXPECT_EQ(packetQuietTime(1000, 0.05f), 19000u);
|
||||
EXPECT_EQ(packetQuietTime(1000, 1.0f), 0u);
|
||||
EXPECT_EQ(packetQuietTime(1000, 3.0f), 0u);
|
||||
|
||||
LongTimer timer;
|
||||
uint32_t now = UINT32_MAX - 100;
|
||||
timer.arm(now, 7u * 86400000u, 0.05f);
|
||||
for (unsigned day = 1; day < 140; ++day) {
|
||||
now += 86400000u;
|
||||
EXPECT_FALSE(timer.ready(now)) << day;
|
||||
}
|
||||
now += 86400000u;
|
||||
EXPECT_TRUE(timer.ready(now));
|
||||
timer.arm(now, 1000, 0.5f);
|
||||
timer.rescale(now + 1000, 0.5f, 1.0f);
|
||||
EXPECT_FALSE(timer.ready(now + 1499));
|
||||
EXPECT_TRUE(timer.ready(now + 1500));
|
||||
}
|
||||
|
||||
TEST(OtaSpeed, ManifestPacingAndRetryAllowancesFollowFactor) {
|
||||
MotaManifest manifest;
|
||||
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest));
|
||||
for (float speed : {0.05f, 0.5f, 1.0f, 3.0f}) {
|
||||
OtaManager server;
|
||||
CapturedMessages sent;
|
||||
server.begin(0, capture_send, &sent);
|
||||
ASSERT_TRUE(server.set_speed(speed));
|
||||
EXPECT_FALSE(server.set_speed(NAN));
|
||||
EXPECT_FLOAT_EQ(server.speed(), speed);
|
||||
server.set_link_timing(80, 2000);
|
||||
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
|
||||
GetManifestMsg request{};
|
||||
memcpy(request.manifest_id, manifest.merkle_root, 4);
|
||||
request.want_mask = 0xffff;
|
||||
uint8_t wire[MAX_PACKET_PAYLOAD];
|
||||
const uint16_t len = encode_get_manifest(wire, sizeof wire, request);
|
||||
server.set_clock(UINT32_MAX - 40); // egress crosses millis rollover
|
||||
ASSERT_TRUE(server.on_message(wire, len));
|
||||
const uint32_t gap = speed == 0.05f ? 3200 : speed == 0.5f ? 320 : speed == 1.0f ? 160 : 54;
|
||||
server.set_clock(UINT32_MAX - 40 + gap - 1);
|
||||
server.serviceEgress();
|
||||
EXPECT_TRUE(sent.items.empty());
|
||||
server.set_clock(UINT32_MAX - 40 + gap);
|
||||
server.serviceEgress();
|
||||
ASSERT_EQ(sent.items.size(), 1u);
|
||||
server.set_clock(UINT32_MAX - 40 + 2 * gap);
|
||||
server.serviceEgress();
|
||||
ASSERT_EQ(sent.items.size(), 2u);
|
||||
EXPECT_EQ(server.pendingManifestJobs(), 0u);
|
||||
const uint32_t timeout = server.fetchRetryTimeoutMs();
|
||||
ASSERT_TRUE(server.set_speed(1.0f));
|
||||
const uint32_t normal = server.fetchRetryTimeoutMs();
|
||||
EXPECT_GE(timeout, normal);
|
||||
if (speed < 1.0f) EXPECT_GT(timeout, normal);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(OtaSpeed, LiveChangeRescalesQueuedMetadataWithoutDroppingIt) {
|
||||
MotaManifest manifest;
|
||||
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest));
|
||||
OtaManager server;
|
||||
CapturedMessages sent;
|
||||
server.begin(0, capture_send, &sent);
|
||||
server.set_link_timing(80, 2000);
|
||||
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
|
||||
GetManifestMsg request{};
|
||||
memcpy(request.manifest_id, manifest.merkle_root, 4);
|
||||
request.want_mask = 0xffff;
|
||||
uint8_t wire[MAX_PACKET_PAYLOAD];
|
||||
server.set_clock(UINT32_MAX - 159); // deadline is exactly zero
|
||||
ASSERT_TRUE(server.on_message(wire, encode_get_manifest(wire, sizeof wire, request)));
|
||||
server.set_clock(UINT32_MAX - 79); // 80 ms remain at 1x
|
||||
ASSERT_TRUE(server.set_speed(0.5f));
|
||||
server.set_clock(79); // 159 ms later
|
||||
server.serviceEgress();
|
||||
EXPECT_TRUE(sent.items.empty());
|
||||
server.set_clock(80);
|
||||
server.serviceEgress();
|
||||
ASSERT_EQ(sent.items.size(), 1u);
|
||||
server.set_clock(160);
|
||||
ASSERT_TRUE(server.set_speed(3.0f)); // 240 ms of 0.5x waiting become 40 ms
|
||||
server.set_clock(199);
|
||||
server.serviceEgress();
|
||||
EXPECT_EQ(sent.items.size(), 1u);
|
||||
server.set_clock(200);
|
||||
server.serviceEgress();
|
||||
EXPECT_EQ(sent.items.size(), 2u);
|
||||
}
|
||||
|
||||
TEST(OtaSpeed, WholeTransferRecoversLostDataAtEverySpeed) {
|
||||
struct Link {
|
||||
OtaManager* destination;
|
||||
std::vector<std::vector<uint8_t>> queue;
|
||||
uint32_t next = 0;
|
||||
bool drop_data = false;
|
||||
static bool send(void* context, const uint8_t* bytes, uint16_t len, bool) {
|
||||
auto& link = *static_cast<Link*>(context);
|
||||
if (link.queue.size() >= 2) return false;
|
||||
link.queue.emplace_back(bytes, bytes + len);
|
||||
return true;
|
||||
}
|
||||
void deliver(uint32_t now, float speed) {
|
||||
if (queue.empty() || (int32_t)(now - next) < 0) return;
|
||||
auto bytes = std::move(queue.front()); queue.erase(queue.begin());
|
||||
next = now + 100 + packetQuietTime(100, speed);
|
||||
if (drop_data && ota_msg_type(bytes.data(), bytes.size()) == OTA_DATA) drop_data = false;
|
||||
else destination->on_message(bytes.data(), bytes.size());
|
||||
}
|
||||
};
|
||||
for (float speed : {0.05f, 0.5f, 1.0f, 3.0f}) {
|
||||
SCOPED_TRACE(speed);
|
||||
OtaManager server, client;
|
||||
OtaStoreRam<4096> store;
|
||||
Link to_client{&client, {}, 0, true}, to_server{&server};
|
||||
server.begin(0, Link::send, &to_client);
|
||||
client.begin(SIM_TARGET_ID, Link::send, &to_server);
|
||||
client.set_fetch_store(&store);
|
||||
client.set_autofetch(OtaManager::AUTOFETCH_ANY);
|
||||
for (auto* manager : {&server, &client}) {
|
||||
ASSERT_TRUE(manager->set_speed(speed));
|
||||
manager->set_link_timing(100, 2000);
|
||||
manager->set_max_hops(0);
|
||||
}
|
||||
ASSERT_TRUE(server.serve(SIM_MOTA_1K, SIM_MOTA_1K_LEN));
|
||||
server.announce();
|
||||
uint32_t next_loop = client.retryDelay(1000);
|
||||
for (uint32_t now = 0; now < 600000 && client.fetchState() != OtaManager::COMPLETE; now += 10) {
|
||||
server.set_clock(now); client.set_clock(now);
|
||||
server.serviceEgress(); client.serviceEgress();
|
||||
to_client.deliver(now, speed); to_server.deliver(now, speed);
|
||||
if (now >= next_loop) {
|
||||
server.loop(); client.loop();
|
||||
next_loop = now + client.retryDelay(1000);
|
||||
}
|
||||
}
|
||||
EXPECT_FALSE(to_client.drop_data);
|
||||
ASSERT_EQ(client.fetchState(), OtaManager::COMPLETE);
|
||||
ASSERT_EQ(store.staged_size(), SIM_MOTA_1K_LEN);
|
||||
EXPECT_EQ(memcmp(store.data(), SIM_MOTA_1K, SIM_MOTA_1K_LEN), 0);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(OtaSpeed, DiscoveryAndCatalogRecoveryRespectSlowerTiming) {
|
||||
OtaManager client;
|
||||
CapturedMessages sent;
|
||||
client.begin(SIM_TARGET_ID, capture_send, &sent);
|
||||
client.set_archive_interest(true);
|
||||
ASSERT_TRUE(client.set_speed(0.05f));
|
||||
AdvMsg adv{};
|
||||
adv.seeder_id[0] = 1; // deterministic jitter of 1 ms before scaling
|
||||
adv.n_motas = 1;
|
||||
uint8_t wire[MAX_PACKET_PAYLOAD];
|
||||
client.set_clock(100);
|
||||
client.on_message(wire, encode_adv(wire, sizeof wire, adv));
|
||||
client.set_clock(6119);
|
||||
client.loop();
|
||||
EXPECT_TRUE(sent.items.empty());
|
||||
client.set_clock(6120);
|
||||
client.loop();
|
||||
ASSERT_EQ(sent.items.size(), 1u);
|
||||
client.set_clock(6120 + 299999);
|
||||
client.loop();
|
||||
EXPECT_EQ(sent.items.size(), 1u);
|
||||
client.set_clock(6120 + 300000);
|
||||
client.loop();
|
||||
EXPECT_EQ(sent.items.size(), 2u);
|
||||
}
|
||||
|
||||
TEST(OtaSpeed, CatalogRetryStaysArmedAtMillisRollover) {
|
||||
for (float speed : {0.05f, 1.0f, 3.0f}) {
|
||||
OtaManager client;
|
||||
CapturedMessages sent;
|
||||
client.begin(SIM_TARGET_ID, capture_send, &sent);
|
||||
ASSERT_TRUE(client.set_speed(speed));
|
||||
AdvMsg adv{};
|
||||
adv.seeder_id[0] = 1; adv.n_motas = 1;
|
||||
uint8_t wire[MAX_PACKET_PAYLOAD];
|
||||
client.on_message(wire, encode_adv(wire, sizeof wire, adv));
|
||||
client.set_clock(0u - client.retryDelay(OTA_CATALOG_RETRY_MS));
|
||||
client.queryAll(); // retry deadline would be zero, the old disarmed sentinel
|
||||
ASSERT_EQ(sent.items.size(), 1u);
|
||||
client.set_clock(0); client.loop();
|
||||
EXPECT_EQ(sent.items.size(), 1u);
|
||||
client.set_clock(1); client.loop();
|
||||
EXPECT_EQ(sent.items.size(), 2u);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(OtaSpeed, SlowRadioSettingDoesNotDelayLocalResumeVerification) {
|
||||
OtaStoreRam<4096> store;
|
||||
ASSERT_TRUE(store.begin(SIM_MOTA_LEN));
|
||||
ASSERT_TRUE(store.write(0, SIM_MOTA, SIM_MOTA_LEN));
|
||||
OtaManager manager;
|
||||
manager.begin(SIM_TARGET_ID, nullptr, nullptr);
|
||||
manager.set_fetch_store(&store);
|
||||
manager.set_autofetch(OtaManager::AUTOFETCH_ANY);
|
||||
ASSERT_TRUE(manager.set_speed(0.05f));
|
||||
EXPECT_EQ(manager.loopIntervalMs(1000), 20000u);
|
||||
ASSERT_TRUE(manager.resumeStaged(nullptr));
|
||||
ASSERT_EQ(manager.fetchState(), OtaManager::VERIFYING_STAGED);
|
||||
EXPECT_EQ(manager.loopIntervalMs(1000), 1000u);
|
||||
manager.loop();
|
||||
EXPECT_EQ(manager.fetchState(), OtaManager::COMPLETE);
|
||||
}
|
||||
|
||||
TEST(OtaSpeed, ProactiveProofPreservesDrainTimeAndSurvivesRollover) {
|
||||
MotaManifest manifest;
|
||||
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
|
||||
const unsigned fragments = (manifest.block_size() + OTA_FRAG_DATA - 1) / OTA_FRAG_DATA;
|
||||
for (uint32_t start : {0u, UINT32_MAX - 599}) {
|
||||
OtaManager server;
|
||||
CapturedMessages sent;
|
||||
server.begin(0, capture_send, &sent);
|
||||
server.set_link_timing(100, 2000); // three packet-service intervals = 600 ms
|
||||
ASSERT_TRUE(server.serve(SIM_MOTA_1K, SIM_MOTA_1K_LEN));
|
||||
ReqMsg request{};
|
||||
memcpy(request.manifest_id, manifest.merkle_root, 4);
|
||||
request.want_mask = 0xffff;
|
||||
uint8_t wire[MAX_PACKET_PAYLOAD];
|
||||
server.set_clock(start);
|
||||
ASSERT_TRUE(server.on_message(wire, encode_req(wire, sizeof wire, request)));
|
||||
for (unsigned i = 0; i < fragments; ++i) server.serviceEgress();
|
||||
ASSERT_EQ(sent.items.size(), fragments);
|
||||
server.set_clock(start + 300);
|
||||
ASSERT_TRUE(server.set_speed(3)); // cannot shorten a physical drain already pending
|
||||
server.set_clock(start + 599); server.serviceEgress();
|
||||
EXPECT_EQ(sent.items.size(), fragments);
|
||||
server.set_clock(start + 601); server.serviceEgress();
|
||||
ASSERT_EQ(sent.items.size(), fragments + 1);
|
||||
EXPECT_EQ(ota_msg_type(sent.items.back().data(), sent.items.back().size()), OTA_PROOF);
|
||||
// Also preserve the drain when the response job starts at 3x.
|
||||
sent.items.clear();
|
||||
server.set_clock(1000);
|
||||
ASSERT_TRUE(server.on_message(wire, encode_req(wire, sizeof wire, request)));
|
||||
for (unsigned i = 0; i < fragments; ++i) server.serviceEgress();
|
||||
server.set_clock(1599); server.serviceEgress();
|
||||
EXPECT_EQ(sent.items.size(), fragments);
|
||||
server.set_clock(1600); server.serviceEgress();
|
||||
EXPECT_EQ(sent.items.size(), fragments + 1);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(OtaSpeed, MissingProofFallbackStaysArmedAtMillisRollover) {
|
||||
MotaManifest manifest;
|
||||
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
|
||||
OtaManager client;
|
||||
OtaStoreRam<4096> store;
|
||||
CapturedMessages sent;
|
||||
client.begin(SIM_TARGET_ID, capture_send, &sent);
|
||||
client.set_fetch_store(&store);
|
||||
ASSERT_TRUE(client.set_speed(0.05f));
|
||||
client.pull(manifest.merkle_root, manifest.target_id);
|
||||
deliver_manifest_fragment(client, manifest.merkle_root, 0, manifest.manifest_start, OTA_MF_FRAG);
|
||||
deliver_manifest_fragment(client, manifest.merkle_root, 1, manifest.manifest_start + OTA_MF_FRAG,
|
||||
(uint16_t)(MOTA_MFL - OTA_MF_FRAG));
|
||||
ASSERT_EQ(client.fetchState(), OtaManager::FETCHING);
|
||||
sent.items.clear();
|
||||
client.set_clock(0u - client.retryDelay(OTA_PROOF_GRACE_MS));
|
||||
uint8_t wire[MAX_PACKET_PAYLOAD];
|
||||
for (uint32_t offset = 0; offset < manifest.block_size(); offset += OTA_FRAG_DATA) {
|
||||
DataMsg data{};
|
||||
memcpy(data.manifest_id, manifest.merkle_root, 4);
|
||||
data.frag_off = offset;
|
||||
data.data = manifest.payload + offset;
|
||||
data.data_len = std::min<uint32_t>(OTA_FRAG_DATA, manifest.block_size() - offset);
|
||||
ASSERT_TRUE(client.on_message(wire, encode_data(wire, sizeof wire, data)));
|
||||
}
|
||||
client.set_clock(0); client.serviceEgress();
|
||||
EXPECT_TRUE(sent.items.empty());
|
||||
client.set_clock(1); client.serviceEgress();
|
||||
ASSERT_EQ(sent.items.size(), 1u);
|
||||
EXPECT_EQ(ota_msg_type(sent.items.back().data(), sent.items.back().size()), OTA_REQ_PROOF);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Exercise production OTA speed CLI/persistence without an OTA workspace."""
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
HARNESS = r'''
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
#include <helpers/ota/OtaSpeedConfig.h>
|
||||
using namespace mesh::ota;
|
||||
int main() {
|
||||
MemoryFS fs;
|
||||
char reply[160];
|
||||
auto command = [&](const char* text, const char* prefix) {
|
||||
memset(reply, 0, sizeof reply);
|
||||
assert(handleSpeedCommand(text, reply, sizeof reply));
|
||||
assert(!strncmp(reply, prefix, strlen(prefix)));
|
||||
};
|
||||
beginSpeedConfig(&fs);
|
||||
command("get ota.speed", "> ota.speed=1x");
|
||||
for (const auto* bad : {"", "0", "-1", "0.049", "3.01", "nan", "inf", "1e0", "1abc", "1 2"}) {
|
||||
const std::string text = std::string("set ota.speed ") + bad;
|
||||
command(text.c_str(), "ERR");
|
||||
assert(speedFactor() == 1.0f && fs.files.empty());
|
||||
}
|
||||
command("set ota.speed .05", "OK ota.speed=0.05x (saved)");
|
||||
assert(validSpeed(speedFactor()));
|
||||
beginSpeedConfig(&fs);
|
||||
command("get ota.speed", "> ota.speed=0.05x");
|
||||
command("ota config speed 3", "OK ota.speed=3x (saved)");
|
||||
command("ota config speed", "> ota.speed=3x");
|
||||
command("ota cfg speed 0.5", "OK ota.speed=0.5x");
|
||||
command("ota speed", "> ota.speed=0.5x");
|
||||
command("get ota.speed 1", "ERR");
|
||||
assert(!handleSpeedCommand("get ota.speed.extra", reply, sizeof reply));
|
||||
assert(!handleSpeedCommand("ota config advert 10", reply, sizeof reply));
|
||||
const auto good = fs.files["/ota_speed"];
|
||||
fs.fail_write = true;
|
||||
command("set ota.speed 2", "ERR");
|
||||
assert(speedFactor() == 0.5f && fs.files["/ota_speed"] == good);
|
||||
fs.fail_write = false;
|
||||
fs.fail_rename = 2; // cannot promote temp; restore primary from backup
|
||||
command("set ota.speed 2", "ERR");
|
||||
assert(speedFactor() == 0.5f && fs.files["/ota_speed"] == good);
|
||||
fs.files["/ota_speed.bak"] = good;
|
||||
fs.files.erase("/ota_speed"); // power cut in rename gap
|
||||
beginSpeedConfig(&fs);
|
||||
assert(speedFactor() == 0.5f && fs.files["/ota_speed"] == good);
|
||||
fs.files["/ota_speed.bak"] = good;
|
||||
fs.files.erase("/ota_speed");
|
||||
fs.fail_rename = 1; // valid backup remains authoritative if repair fails
|
||||
beginSpeedConfig(&fs);
|
||||
assert(speedFactor() == 0.5f);
|
||||
command("set ota.speed 2", "ERR");
|
||||
assert(fs.files["/ota_speed.bak"] == good);
|
||||
beginSpeedConfig(&fs);
|
||||
assert(speedFactor() == 0.5f && fs.files["/ota_speed"] == good);
|
||||
fs.files["/ota_speed"][4] ^= 1; // damaged image is not partly adopted
|
||||
beginSpeedConfig(&fs);
|
||||
assert(speedFactor() == 1.0f);
|
||||
command("set ota.speed 2", "ERR");
|
||||
assert(speedFactor() == 1.0f);
|
||||
fs.files.clear();
|
||||
beginSpeedConfig(&fs);
|
||||
command("ota set speed 1.125", "OK ota.speed=1.125x");
|
||||
beginSpeedConfig(&fs);
|
||||
assert(speedFactor() == 1.125f);
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class OtaSpeedTest(unittest.TestCase):
|
||||
def test_commands_range_storage_and_failure_recovery(self):
|
||||
compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
self.assertIsNotNone(compiler)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
source = Path(directory) / "speed.cpp"
|
||||
source.write_text(HARNESS)
|
||||
binary = Path(directory) / "speed"
|
||||
result = subprocess.run([
|
||||
compiler, "-std=c++17", "-Wall", "-Wextra", "-DENABLE_OTA=1",
|
||||
"-I", str(ROOT / "test/fixtures/radio_profiles/mocks"),
|
||||
"-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"),
|
||||
str(ROOT / "src/helpers/ota/OtaSpeedConfig.cpp"), str(source),
|
||||
"-o", str(binary),
|
||||
], text=True, capture_output=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
subprocess.run([str(binary)], check=True)
|
||||
|
||||
def test_shared_role_wiring_and_idle_context_access(self):
|
||||
common = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
|
||||
companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
|
||||
for text in (common, companion):
|
||||
self.assertIn("mesh::ota::beginSpeedConfig(", text)
|
||||
self.assertIn("mesh::ota::handleSpeedCommand(command, reply,", text)
|
||||
cli = (ROOT / "src/helpers/ota/OtaCli.cpp").read_text()
|
||||
self.assertLess(cli.index("handleSpeedCommand(command"), cli.index("ota_acquire_context(reply"))
|
||||
mesh = (ROOT / "src/Mesh.cpp").read_text()
|
||||
self.assertIn("syncOtaTiming(context->manager)", mesh)
|
||||
self.assertIn("syncOtaTiming(ota::ota_ctx().manager)", mesh)
|
||||
self.assertIn("manager.set_speed(getOtaSpeedFactor())", mesh)
|
||||
self.assertIn("_ota_announce_timer.arm(_ms->getMillis(), gap, ota_speed)", mesh)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -113,6 +113,8 @@ public:
|
||||
bool bypass_rx_delay = false;
|
||||
bool configured_cad_enabled = false;
|
||||
int configured_agc_interval = 0;
|
||||
float ota_speed = 1.0f;
|
||||
float getOtaSpeedFactor() const override { return ota_speed; }
|
||||
|
||||
int getAGCResetInterval() const override {
|
||||
return configured_agc_interval;
|
||||
@@ -1170,6 +1172,59 @@ TEST(RxReservePacketManager, DispatcherNotifiesBeforeReleasingStaleOutbound) {
|
||||
EXPECT_EQ(0, radio.send_starts);
|
||||
}
|
||||
|
||||
TEST(RxReservePacketManager, PacingPausesExpiryWithoutErasingPriorCongestion) {
|
||||
for (uint32_t start : {0u, UINT32_MAX - 20000}) {
|
||||
RxReservePacketManager manager(8, 4);
|
||||
auto* paced = manager.allocNew();
|
||||
auto* stale = manager.allocNew();
|
||||
ASSERT_TRUE(manager.queueOutbound(paced, 0, start));
|
||||
ASSERT_TRUE(manager.queueOutbound(stale, 1, start));
|
||||
// Already spent 29 seconds waiting. One minute of deliberate pacing must
|
||||
// not expire this packet, or grant another full 30 seconds afterwards.
|
||||
for (uint32_t elapsed = 29000; elapsed < 89000; elapsed += 100) {
|
||||
ASSERT_EQ(manager.peekNextOutbound(start + elapsed), paced);
|
||||
ASSERT_TRUE(manager.deferOutboundForPacing(paced, start + elapsed, start + elapsed + 100));
|
||||
}
|
||||
EXPECT_EQ(manager.getNextDroppedOutbound(), stale);
|
||||
manager.free(stale);
|
||||
EXPECT_EQ(manager.peekNextOutbound(start + 90000), paced);
|
||||
EXPECT_EQ(manager.peekNextOutbound(start + 90001), nullptr);
|
||||
EXPECT_EQ(manager.getNextDroppedOutbound(), paced);
|
||||
manager.free(paced);
|
||||
EXPECT_EQ(manager.getFreeCount(), 8);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(RxReservePacketManager, DispatcherKeepsOtaQueuedAcrossSlowQuietWindow) {
|
||||
RxReservePacketManager manager(8, 4);
|
||||
TestClock clock;
|
||||
TestRadio radio;
|
||||
TestDispatcher dispatcher(radio, clock, manager);
|
||||
dispatcher.begin();
|
||||
dispatcher.ota_speed = 0.05f;
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
auto* packet = dispatcher.obtainNewPacket();
|
||||
ASSERT_NE(packet, nullptr);
|
||||
packet->header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_OTA << PH_TYPE_SHIFT);
|
||||
packet->payload_len = 1; packet->payload[0] = i;
|
||||
ASSERT_TRUE(dispatcher.sendPacket(packet, 0));
|
||||
}
|
||||
clock.now = 1; dispatcher.loop();
|
||||
ASSERT_EQ(radio.send_starts, 1);
|
||||
clock.now = 1801; radio.send_complete = true; dispatcher.loop();
|
||||
radio.send_complete = false;
|
||||
for (clock.now = 1901; clock.now < 36001; clock.now += 100) {
|
||||
dispatcher.loop();
|
||||
ASSERT_EQ(radio.send_starts, 1);
|
||||
ASSERT_EQ(dispatcher.failed_packet, nullptr);
|
||||
}
|
||||
clock.now = 36001; dispatcher.loop(); // 34.2 seconds of intentional quiet
|
||||
EXPECT_EQ(radio.send_starts, 2);
|
||||
EXPECT_EQ(dispatcher.failed_packet, nullptr);
|
||||
clock.now++; radio.send_complete = true; dispatcher.loop();
|
||||
EXPECT_EQ(manager.getFreeCount(), 8);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
|
||||
Reference in new issue
Block a user