Preserve OTA block progress and channel checks during radio recovery
Drain received packets in single-profile radio fault backoff as well as dual-profile mode. Share the normal channel-clear and bounded busy policy with driver retries so a failed send cannot retry blindly or bypass the existing single-retry limit. Retain an unadmitted OTA request flight and count v2 fallback evidence only after a sparse retry enters the transmit queue. Local queue congestion no longer disables v2 before a request was sent or discards received fragments. Include primary airtime when temporary RX-only radio2 crosses requests onto that channel, keeping response allowances conservative on slower profiles. Add six native regressions, including five failing-before cases, and document the queue-admission and crossover timing behavior. Validation: 1,502 native tests, 29 integration checks, and five firmware builds passed across ESP32-S3, nRF52, and STM32.
This commit is contained in:
1 parent
0582366527
commit
dde87f1d15
9 files changed
+216
-44
No files matched your search
+5
-2
@@ -178,8 +178,11 @@ does not reflect the GoogleTest count -- run the built binary directly
|
||||
ordinary traffic during OTA pacing; `test_packet_manager` checks that intentional
|
||||
pacing survives stale expiry without erasing prior congestion age.
|
||||
- OTA delivery: `test_ota` keeps rejected queue admissions out of metadata retry
|
||||
limits; `test_trace_retry` advances the clock during CAD to catch selection and
|
||||
pacing bypasses and cancels retries while the chip stays BUSY.
|
||||
limits and preserves v2 negotiation/received fragments during blocked block
|
||||
requests; `test_trace_retry` advances the clock during CAD to catch selection
|
||||
and pacing bypasses, cancels retries while the chip stays BUSY, and checks
|
||||
channel clearance, RX draining, and bounded recovery for radio fault retries.
|
||||
It also accounts for the primary's airtime when an RX-only radio2 crosses TX.
|
||||
`python3 -B test/test_ota_apply_delivery.py -v` executes the
|
||||
production apply/TX guards with a reply on air, a busy queue, failed persistence,
|
||||
and clock rollover. It checks application and bootloader reboot paths.
|
||||
|
||||
+67
@@ -182,6 +182,73 @@ TEST_F(DualProfileTest, OtaAirtimeFollowsParticipatingProfilesInsteadOfScannerVi
|
||||
EXPECT_EQ(node.getOtaPacketAirtime(), 900u);
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, OtaAirtimeIncludesPrimaryWhenCrossingFromReceiveOnlySecondary) {
|
||||
radio.distinguish_airtime = true;
|
||||
radio.config.secondary_temporary = true;
|
||||
radio.config.secondary.mode = mesh::RadioProfileMode::Rx;
|
||||
radio.config.cross = mesh::RadioCrossMode::On;
|
||||
auto packet = makeFloodPacket(PAYLOAD_TYPE_OTA);
|
||||
EXPECT_EQ(node.getTransmitProfileMask(&packet), 1u);
|
||||
// The secondary receives the update, but outgoing requests cross onto the
|
||||
// much slower primary. Both contribute to the response/retry allowance.
|
||||
EXPECT_EQ(node.getOtaPacketAirtime(), 970u);
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, RadioFaultRetryWaitsForChannelClearInSingleAndDualMode) {
|
||||
node.flood_attempts = 0;
|
||||
radio.config.cross = mesh::RadioCrossMode::Off;
|
||||
for (bool dual : {false, true}) {
|
||||
radio.config.secondary.mode = dual ? mesh::RadioProfileMode::RxTx : mesh::RadioProfileMode::Off;
|
||||
radio.fail_next_send = true;
|
||||
ASSERT_NE(nullptr, queue());
|
||||
tick(); ASSERT_FALSE(radio.sending);
|
||||
const auto before = radio.transmissions.size();
|
||||
radio.busy_profile = 0;
|
||||
tick(500);
|
||||
EXPECT_FALSE(radio.sending);
|
||||
EXPECT_EQ(radio.transmissions.size(), before);
|
||||
radio.busy_profile = -1;
|
||||
tick(500);
|
||||
EXPECT_TRUE(radio.sending);
|
||||
radio.complete = true; tick();
|
||||
EXPECT_EQ(manager.getFreeCount(), 40);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, SingleProfileRadioRetryDrainsReceivedPacketBeforeTransmitting) {
|
||||
node.flood_attempts = 0;
|
||||
radio.config.secondary.mode = mesh::RadioProfileMode::Off;
|
||||
radio.fail_next_send = true;
|
||||
ASSERT_NE(nullptr, queue());
|
||||
tick(); ASSERT_FALSE(radio.sending);
|
||||
radio.incoming = {uint8_t(ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT)), 0, 0x42};
|
||||
tick(500);
|
||||
EXPECT_TRUE(radio.incoming.empty());
|
||||
EXPECT_EQ(radio.config.rx_packets[0], 1u);
|
||||
EXPECT_EQ((std::vector<uint8_t>{0}), radio.transmissions);
|
||||
radio.complete = true; tick();
|
||||
EXPECT_EQ(manager.getFreeCount(), 40);
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, RadioFaultRetryRetainsBoundedBusyEscapeAndSingleRetryLimit) {
|
||||
node.flood_attempts = 0;
|
||||
radio.config.cross = mesh::RadioCrossMode::Off;
|
||||
radio.fail_next_send = true;
|
||||
ASSERT_NE(nullptr, queue());
|
||||
tick(); ASSERT_FALSE(radio.sending);
|
||||
radio.busy_profile = 0;
|
||||
tick(500); EXPECT_FALSE(radio.sending);
|
||||
// The same bounded CAD policy used for first sends still permits recovery
|
||||
// from a permanently busy detector. It does not grant another radio retry.
|
||||
radio.fail_next_send = true;
|
||||
tick(10000);
|
||||
EXPECT_FALSE(radio.fail_next_send);
|
||||
EXPECT_FALSE(radio.sending);
|
||||
EXPECT_EQ(manager.getFreeCount(), 40);
|
||||
tick(10000);
|
||||
EXPECT_TRUE(radio.transmissions.empty());
|
||||
}
|
||||
|
||||
TEST_F(DualProfileTest, PacketBecomingReadyDuringCadCannotSkipItsOwnChannelCheck) {
|
||||
node.flood_attempts = 0;
|
||||
node.tempRadioActive = true;
|
||||
|
||||
@@ -2963,6 +2963,75 @@ TEST(OtaTransfer, NewClientRequestsV2ThenRetriesLegacyWhenNoV2DataArrives) {
|
||||
EXPECT_EQ(fallback.want_mask, 0x007Fu);
|
||||
}
|
||||
|
||||
TEST(OtaTransfer, RejectedFirstFlightDoesNotDisableV2BeforeAnyRequestWasSent) {
|
||||
MotaManifest manifest;
|
||||
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
|
||||
OtaManager client;
|
||||
OtaStoreRam<4096> store;
|
||||
GatedCapture sent;
|
||||
client.begin(SIM_TARGET_ID, gated_capture_send, &sent);
|
||||
client.set_fetch_store(&store);
|
||||
client.set_clock(100);
|
||||
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);
|
||||
for (uint32_t i = 1; i <= 10; ++i) {
|
||||
client.set_clock(100 + i * client.fetchRetryTimeoutMs());
|
||||
client.loop();
|
||||
}
|
||||
EXPECT_TRUE(sent.items.empty());
|
||||
sent.accept = true;
|
||||
client.loop();
|
||||
ASSERT_EQ(sent.items.size(), 1u);
|
||||
ReqMsg first{};
|
||||
ASSERT_TRUE(decode_req(sent.items[0].data(), (uint16_t)sent.items[0].size(), first));
|
||||
EXPECT_TRUE(ota_req_is_v2(first.want_mask));
|
||||
}
|
||||
|
||||
TEST(OtaTransfer, RejectedSparseRetryKeepsReceivedV2Fragments) {
|
||||
MotaManifest manifest;
|
||||
ASSERT_TRUE(mota_parse(SIM_MOTA_1K, SIM_MOTA_1K_LEN, manifest));
|
||||
OtaManager client;
|
||||
OtaStoreRam<4096> store;
|
||||
GatedCapture sent;
|
||||
sent.accept = true;
|
||||
client.begin(SIM_TARGET_ID, gated_capture_send, &sent);
|
||||
client.set_fetch_store(&store);
|
||||
client.set_clock(100);
|
||||
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));
|
||||
uint8_t body[OTA_DATA_V2_STREAM_ID_BYTES + OTA_FRAG_DATA_V2];
|
||||
mh4(body, manifest.payload, manifest.block_size());
|
||||
memcpy(body + OTA_DATA_V2_STREAM_ID_BYTES, manifest.payload, OTA_FRAG_DATA_V2);
|
||||
DataMsg partial{};
|
||||
memcpy(partial.manifest_id, manifest.merkle_root, 4);
|
||||
ASSERT_TRUE(ota_data_v2_pack(0, (uint16_t)manifest.block_size(), false, partial.frag_off));
|
||||
partial.data = body;
|
||||
partial.data_len = sizeof body;
|
||||
uint8_t wire[MAX_PACKET_PAYLOAD];
|
||||
ASSERT_TRUE(client.on_message(wire, encode_data(wire, sizeof wire, partial)));
|
||||
sent.items.clear();
|
||||
sent.accept = false;
|
||||
uint32_t now = 100;
|
||||
for (unsigned i = 0; i < 10; ++i) {
|
||||
now += client.fetchRetryTimeoutMs();
|
||||
client.set_clock(now);
|
||||
client.loop();
|
||||
}
|
||||
EXPECT_TRUE(sent.items.empty());
|
||||
sent.accept = true;
|
||||
client.loop();
|
||||
ASSERT_EQ(sent.items.size(), 1u);
|
||||
ReqMsg retry{};
|
||||
ASSERT_TRUE(decode_req(sent.items[0].data(), (uint16_t)sent.items[0].size(), retry));
|
||||
EXPECT_TRUE(ota_req_is_v2(retry.want_mask));
|
||||
EXPECT_EQ(ota_req_v2_fragments(retry.want_mask) & 1u, 0u); // keep fragment zero
|
||||
}
|
||||
|
||||
TEST(OtaTransfer, TwoKilobyteClientAcceptsCanonicalLegacyFallbackThroughBitTwelve) {
|
||||
MotaManifest manifest;
|
||||
ASSERT_TRUE(mota_parse(SIM_MOTA_2K, SIM_MOTA_2K_LEN, manifest));
|
||||
|
||||
Reference in new issue
Block a user