From ea4b53ceee217f8eff4573571909c5c32454b995 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 23 Oct 2026 15:00:42 -0700 Subject: [PATCH] Enable direct-route echo retries for telemetry snapshots --- docs/cli_commands.md | 21 +- docs/telemetry_decoder.md | 9 + src/Mesh.cpp | 136 +++++------ test/test_trace_retry/test_trace_retry.cpp | 270 +++++++++++++++++++++ 4 files changed, 349 insertions(+), 87 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 87a1924ef..b27cd3213 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -865,8 +865,16 @@ history is still sent. It uses all currently available base positions up to 165 and all available I2C positions up to 192 per populated channel, plus the latest 255 positions per noise profile (63 hours 45 minutes). It works while the schedule is off and does not move the next -scheduled send time. RAW_CUSTOM direct packets do not enter the normal -encrypted direct-message retry mechanism. +scheduled send time. + +With a non-empty hop path, `TTB1`, `TVB1`, `IVB1`, `N1B1`, and `N2B1` telemetry +use the existing direct-message echo retries. They follow `direct.retry`, +`direct.retry.count`, `direct.retry.heard`, and the direct-retry coding-rate +policy. A matching downstream forwarding echo stops retries. The default +zero-hop `direct` route has no forwarding echo and does not use these retries. +Updated relays can retry their own forwarding hop; stock relays remain compatible +and their forwarded packets can provide the echo. The packet format is unchanged: +there is no new ACK or confirmation that the final observer received the data. Full temperature and battery snapshots are 184-byte RAW_CUSTOM payloads. Manual tests can be shorter while history fills. Both formats are binary, @@ -5038,7 +5046,10 @@ region save --- ### Direct Retry -Direct retry resends direct-routed packets when the downstream echo is not heard. It applies to direct messages, ACK packets, multipart packets carrying ACK payloads, and TRACE packets. +Direct retry resends direct-routed packets when the downstream echo is not heard. +It applies to direct messages, ACK packets, multipart packets carrying ACK +payloads, TRACE packets, and recognized RAW_CUSTOM telemetry (`TTB1`, `TVB1`, +`IVB1`, `N1B1`, `N2B1`) with a non-empty hop path. The shared state, count, base, and step controls work on repeater, room-server, and sensor firmware. Recent-repeater/SNR controls are repeater-only because the @@ -5396,8 +5407,8 @@ set flood.retry.bucket 2 none **Default:** `15` with the `rooftop` preset **Note:** This setting limits retries for eligible direct-routed packets such as -traces, requests, responses, and ACKs. Direct-routed **text messages (type 2)** -are an exception: they allow **up to 21 retries after the original send**, +traces, requests, responses, ACKs, and recognized telemetry. Direct-routed +**text messages (type 2)** are an exception: they allow **up to 21 retries after the original send**, regardless of `direct.retry.count` or the repeater short-path cap. For example, `set direct.retry.count 1` limits eligible non-text packets to one retry, but does not reduce the text-message limit. diff --git a/docs/telemetry_decoder.md b/docs/telemetry_decoder.md index 8c4796cd1..35902064c 100644 --- a/docs/telemetry_decoder.md +++ b/docs/telemetry_decoder.md @@ -38,6 +38,15 @@ set data.tx path A1B2,C3D4 send telemetry.tx now ``` +Telemetry with a non-empty hop path uses the existing direct-message echo +retries, following `direct.retry`, `direct.retry.count`, `direct.retry.heard`, +and the direct-retry coding-rate policy. Retries stop when a matching downstream +forwarding echo is heard. The default zero-hop `direct` route does not use echo retries. +Updated relays can retry their own forwarding hop; stock relays remain compatible +and their forwarding can provide the echo. This adds no packet format or ACK: +an echo confirms forwarding, not final delivery to the observer. A queue failure +still retries only the packet that failed to queue, after 30 minutes. + The shared route does not enable telemetry. Automatic telemetry is off on a fresh install; to explicitly keep the route manual-only before sending: diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 92dee74fa..c77c2419a 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -58,6 +58,29 @@ static bool hasValidEncryptedPayloadLength(uint16_t payload_len, uint16_t clear_ return ((payload_len - overhead) % CIPHER_BLOCK_SIZE) == 0; } +static bool isTelemetryHistoryPayload(const Packet* packet) { + const uint8_t* data = packet->payload; + const uint16_t length = packet->payload_len; + if ((uint16_t)(length - 20U) > MAX_PACKET_PAYLOAD - 20U + || data[2] != 'B' || data[3] != '1' + || (data[16] | data[17]) == 0 || data[18] == 0) return false; + + // Recognize complete B1 history envelopes, not arbitrary custom traffic + // which happens to begin with a telemetry tag. + const uint16_t count = data[18]; + if (data[0] == 'T' && (data[1] == 'T' || data[1] == 'V')) { + // The payload-size bound also caps these one-byte samples at 165. + return length == 19U + count; + } + if (data[0] == 'I' && data[1] == 'V') { + const uint16_t samples = data[19]; + return samples != 0 && samples <= 64 + && length == 20U + (samples * 15U + 7U) / 8U; + } + return data[0] == 'N' && (uint8_t)(data[1] - '1') <= 1 + && length == 19U + (count * 5U + 7U) / 8U; +} + static uint8_t decodeTraceHashSize(uint8_t flags, uint8_t route_bytes) { uint8_t code = flags & 0x03; uint8_t size_pow2 = (uint8_t)(1U << code); // legacy TRACE interpretation @@ -301,31 +324,7 @@ void Mesh::begin() { _next_recent_advert_echo = 0; _next_direct_retry_timeout = 0; _next_flood_retry_timeout = 0; - for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; i++) { - _direct_retries[i].packet = NULL; - _direct_retries[i].trigger_packet = NULL; - _direct_retries[i].retry_started_at = 0; - _direct_retries[i].echo_wait_started_at = 0; - _direct_retries[i].retry_at = 0; - _direct_retries[i].retry_delay = 0; - _direct_retries[i].message_timestamp = 0; - _direct_retries[i].retry_attempts_sent = 0; - memset(_direct_retries[i].retry_key, 0, sizeof(_direct_retries[i].retry_key)); - memset(_direct_retries[i].trace_replacement_key, 0, sizeof(_direct_retries[i].trace_replacement_key)); - memset(_direct_retries[i].message_replacement_key, 0, - sizeof(_direct_retries[i].message_replacement_key)); - memset(_direct_retries[i].next_hop_hash, 0, sizeof(_direct_retries[i].next_hop_hash)); - _direct_retries[i].next_hop_hash_len = 0; - _direct_retries[i].payload_type = 0; - _direct_retries[i].priority = 0; - _direct_retries[i].progress_marker = 0; - _direct_retries[i].expect_path_growth = false; - _direct_retries[i].final_hop_retry = false; - _direct_retries[i].waiting_final_echo = false; - _direct_retries[i].queued = false; - _direct_retries[i].has_message_replacement_key = false; - _direct_retries[i].active = false; - } + memset(_direct_retries, 0, sizeof(_direct_retries)); for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) { _flood_retries[i].packet = NULL; _flood_retries[i].trigger_packet = NULL; @@ -1645,29 +1644,7 @@ void Mesh::clearDirectRetrySlot(int idx) { if (_direct_retries[idx].active && _active_direct_retry_count > 0) { _active_direct_retry_count--; } - _direct_retries[idx].packet = NULL; - _direct_retries[idx].trigger_packet = NULL; - _direct_retries[idx].retry_started_at = 0; - _direct_retries[idx].echo_wait_started_at = 0; - _direct_retries[idx].retry_at = 0; - _direct_retries[idx].retry_delay = 0; - _direct_retries[idx].message_timestamp = 0; - _direct_retries[idx].retry_attempts_sent = 0; - memset(_direct_retries[idx].retry_key, 0, sizeof(_direct_retries[idx].retry_key)); - memset(_direct_retries[idx].trace_replacement_key, 0, sizeof(_direct_retries[idx].trace_replacement_key)); - memset(_direct_retries[idx].message_replacement_key, 0, - sizeof(_direct_retries[idx].message_replacement_key)); - memset(_direct_retries[idx].next_hop_hash, 0, sizeof(_direct_retries[idx].next_hop_hash)); - _direct_retries[idx].next_hop_hash_len = 0; - _direct_retries[idx].payload_type = 0; - _direct_retries[idx].priority = 0; - _direct_retries[idx].progress_marker = 0; - _direct_retries[idx].expect_path_growth = false; - _direct_retries[idx].final_hop_retry = false; - _direct_retries[idx].waiting_final_echo = false; - _direct_retries[idx].queued = false; - _direct_retries[idx].has_message_replacement_key = false; - _direct_retries[idx].active = false; + memset(&_direct_retries[idx], 0, sizeof(_direct_retries[idx])); if (rebuild_timeout) rebuildNextDirectRetryTimeout(); } @@ -2028,6 +2005,11 @@ void Mesh::clearPendingDirectRetryOnSendFail(const Packet* packet) { bool Mesh::getDirectRetryTarget(const Packet* packet, const uint8_t*& next_hop_hash, uint8_t& next_hop_hash_len, uint8_t& progress_marker, bool& expect_path_growth) const { switch (packet->getPayloadType()) { + case PAYLOAD_TYPE_RAW_CUSTOM: + if (!isTelemetryHistoryPayload(packet)) return false; + // Telemetry uses the same shrinking-path echo as other direct packets. + // Zero-hop sends have no forwarding echo and remain excluded below. + // fall through case PAYLOAD_TYPE_ACK: case PAYLOAD_TYPE_PATH: case PAYLOAD_TYPE_REQ: @@ -2036,24 +2018,13 @@ bool Mesh::getDirectRetryTarget(const Packet* packet, const uint8_t*& next_hop_h case PAYLOAD_TYPE_ANON_REQ: // Allow retries even when only one downstream hop remains so fixed direct paths // (e.g. remote admin/login over 2-hop chains) use the same retry policy. - if (packet->getPathHashCount() == 0) { - return false; - } - next_hop_hash = packet->path; - next_hop_hash_len = packet->getPathHashSize(); - progress_marker = packet->getPathHashCount(); - expect_path_growth = false; - return true; + break; case PAYLOAD_TYPE_MULTIPART: - if (packet->payload_len < 1 || (packet->payload[0] & 0x0F) != PAYLOAD_TYPE_ACK || packet->getPathHashCount() == 0) { + if (packet->payload_len < 1 || (packet->payload[0] & 0x0F) != PAYLOAD_TYPE_ACK) { return false; } - next_hop_hash = packet->path; - next_hop_hash_len = packet->getPathHashSize(); - progress_marker = packet->getPathHashCount(); - expect_path_growth = false; - return true; + break; case PAYLOAD_TYPE_TRACE: { if (packet->payload_len < 9) { @@ -2077,6 +2048,13 @@ bool Mesh::getDirectRetryTarget(const Packet* packet, const uint8_t*& next_hop_h default: return false; } + + if (packet->getPathHashCount() == 0) return false; + next_hop_hash = packet->path; + next_hop_hash_len = packet->getPathHashSize(); + progress_marker = packet->getPathHashCount(); + expect_path_growth = false; + return true; } bool Mesh::canDecodeDirectPayloadForSelf(const Packet* packet) { @@ -2202,27 +2180,21 @@ void Mesh::maybeScheduleDirectRetry(const Packet* packet, uint8_t priority, bool // Only store retry metadata here; allocate the retry packet after the initial TX really completes. uint32_t retry_delay = getDirectRetryAttemptDelay(packet, 0); - memcpy(_direct_retries[slot_idx].retry_key, retry_key, sizeof(retry_key)); - memcpy(_direct_retries[slot_idx].trace_replacement_key, trace_replacement_key, + DirectRetryEntry& entry = _direct_retries[slot_idx]; + memset(&entry, 0, sizeof(entry)); + memcpy(entry.retry_key, retry_key, sizeof(retry_key)); + memcpy(entry.trace_replacement_key, trace_replacement_key, sizeof(trace_replacement_key)); - _direct_retries[slot_idx].packet = NULL; - _direct_retries[slot_idx].trigger_packet = const_cast(packet); - _direct_retries[slot_idx].retry_started_at = 0; - _direct_retries[slot_idx].echo_wait_started_at = 0; - _direct_retries[slot_idx].retry_at = 0; - _direct_retries[slot_idx].retry_delay = retry_delay; - _direct_retries[slot_idx].retry_attempts_sent = 0; - memset(_direct_retries[slot_idx].next_hop_hash, 0, sizeof(_direct_retries[slot_idx].next_hop_hash)); - memcpy(_direct_retries[slot_idx].next_hop_hash, next_hop_hash, next_hop_hash_len); - _direct_retries[slot_idx].next_hop_hash_len = next_hop_hash_len; - _direct_retries[slot_idx].payload_type = packet->getPayloadType(); - _direct_retries[slot_idx].priority = priority; - _direct_retries[slot_idx].progress_marker = progress_marker; - _direct_retries[slot_idx].expect_path_growth = expect_path_growth; - _direct_retries[slot_idx].final_hop_retry = final_hop_retry; - _direct_retries[slot_idx].waiting_final_echo = false; - _direct_retries[slot_idx].queued = false; - _direct_retries[slot_idx].active = true; + entry.trigger_packet = const_cast(packet); + entry.retry_delay = retry_delay; + memcpy(entry.next_hop_hash, next_hop_hash, next_hop_hash_len); + entry.next_hop_hash_len = next_hop_hash_len; + entry.payload_type = packet->getPayloadType(); + entry.priority = priority; + entry.progress_marker = progress_marker; + entry.expect_path_growth = expect_path_growth; + entry.final_hop_retry = final_hop_retry; + entry.active = true; _active_direct_retry_count++; } diff --git a/test/test_trace_retry/test_trace_retry.cpp b/test/test_trace_retry/test_trace_retry.cpp index 51a0d3084..b5d1b795a 100644 --- a/test/test_trace_retry/test_trace_retry.cpp +++ b/test/test_trace_retry/test_trace_retry.cpp @@ -426,6 +426,276 @@ TEST_F(RetryCodingRateTest, DirectTransmissionsKeepTheirScheduleWithFloodRetryOf } } +class TelemetryRetryMesh : public RetryCodingRateMesh { +public: + using RetryCodingRateMesh::RetryCodingRateMesh; + bool direct_enabled = true; + bool allowDirectRetry(const mesh::Packet*, const uint8_t*, uint8_t) const override { + return direct_enabled; + } +}; + +class TelemetryDirectRetryTest : public testing::TestWithParam { +protected: + TraceTestClock clock; + TraceTestRTC rtc; + TraceTestRNG rng; + RetryCodingRateRadio radio; + TraceTestTables tables; + StaticPoolPacketManager manager{12}; + TelemetryRetryMesh node{radio, clock, rng, rtc, manager, tables}; + const uint8_t path[2] = {0x12, 0x34}; + + void SetUp() override { + node.begin(); + node.direct_attempts = 2; + } + + std::vector snapshot(uint8_t count = 4) const { + const char* magic = GetParam(); + const bool external = memcmp(magic, "IVB1", 4) == 0; + const uint8_t bits = external ? 15 : magic[0] == 'N' ? 5 : 8; + const size_t header = external ? 20 : 19; + std::vector payload(header + (count * bits + 7U) / 8U, 0x51); + memcpy(payload.data(), magic, 4); + payload[16] = magic[0] == 'N' ? 15 : 30; + payload[17] = 0; + payload[18] = external ? 1 : count; + if (external) payload[19] = count; + return payload; + } + + mesh::Packet* queue(const std::vector& payload, uint8_t hops = 2) { + auto* packet = node.createRawData(payload.data(), payload.size()); + EXPECT_NE(nullptr, packet); + if (packet && !node.sendDirect(packet, path, hops)) { + ADD_FAILURE() << "Could not queue telemetry packet"; + return nullptr; + } + return packet; + } + + void transmitNext() { + clock.now += 10000; + node.loop(); + ASSERT_TRUE(radio.sending); + radio.complete = true; + ++clock.now; + node.loop(); + ASSERT_FALSE(radio.sending); + } + + void expectIdle() { + clock.now += 10000; + node.loop(); + EXPECT_FALSE(radio.sending); + EXPECT_EQ(0, manager.getOutboundTotal()); + EXPECT_EQ(12, manager.getFreeCount()); + } +}; + +TEST_P(TelemetryDirectRetryTest, MissingEchoUsesTheDirectRetryBudgetAndCodingRates) { + auto* packet = queue(snapshot()); + ASSERT_NE(nullptr, packet); + const mesh::Packet original = *packet; + for (uint8_t attempt = 0; attempt <= node.direct_attempts; ++attempt) { + transmitNext(); + if (attempt == node.direct_attempts) continue; + ASSERT_EQ(1, manager.getOutboundTotal()); + const auto* retry = manager.getOutboundByIdx(0); + EXPECT_EQ(original.header, retry->header); + EXPECT_EQ(original.payload_len, retry->payload_len); + EXPECT_EQ(0, memcmp(original.payload, retry->payload, original.payload_len)); + EXPECT_EQ(original.path_len, retry->path_len); + EXPECT_EQ(0, memcmp(path, retry->path, sizeof(path))); + EXPECT_EQ(original.radio_profile, retry->radio_profile); + } + expectIdle(); + EXPECT_EQ((std::vector{5, 5, 7}), radio.transmitted_crs); +} + +TEST_P(TelemetryDirectRetryTest, RetryIsAllocatedOnlyAfterTheRadioCompletesSending) { + ASSERT_NE(nullptr, queue(snapshot())); + ++clock.now; + node.loop(); + ASSERT_TRUE(radio.sending); + EXPECT_EQ(0, manager.getOutboundTotal()); + clock.now += 10; // still within this fake radio's 15 ms TX timeout + node.loop(); + EXPECT_TRUE(radio.sending); + EXPECT_EQ(0, manager.getOutboundTotal()); + EXPECT_EQ(1U, radio.transmitted_crs.size()); + radio.complete = true; + ++clock.now; + node.loop(); + EXPECT_FALSE(radio.sending); + ASSERT_EQ(1, manager.getOutboundTotal()); + transmitNext(); + EXPECT_EQ(2U, radio.transmitted_crs.size()); +} + +TEST_P(TelemetryDirectRetryTest, MatchingDownstreamEchoCancelsTheQueuedRetry) { + auto* packet = queue(snapshot()); + ASSERT_NE(nullptr, packet); + mesh::Packet echo = *packet; + transmitNext(); + ASSERT_EQ(1, manager.getOutboundTotal()); + echo.setPathHashCount(1); + echo.path[0] = path[1]; + node.receivePacket(&echo); + expectIdle(); + EXPECT_EQ((std::vector{5}), radio.transmitted_crs); +} + +TEST_P(TelemetryDirectRetryTest, RelaysRetryTheRemainingRouteForEveryHashWidth) { + node.forwardFloods = true; + const uint8_t route[] = {0x12, 0x13, 0x14, 0x34, 0x35, 0x36, 0x56, 0x57, 0x58}; + for (uint8_t width : {1, 2, 3}) { + SCOPED_TRACE(width); + const auto payload = snapshot(); + auto* incoming = node.createRawData(payload.data(), payload.size()); + ASSERT_NE(nullptr, incoming); + incoming->header |= ROUTE_TYPE_DIRECT; + incoming->setPathHashSizeAndCount(width, 3); + memcpy(incoming->path, route, width * 3); + memcpy(node.self_id.pub_key, route, width); + ASSERT_NE(ACTION_RELEASE, node.receivePacket(incoming)); + EXPECT_TRUE(incoming->forwarded); + EXPECT_EQ(width, incoming->getPathHashSize()); + EXPECT_EQ(2, incoming->getPathHashCount()); + EXPECT_EQ(0, memcmp(route + width, incoming->path, width * 2)); + const mesh::Packet forwarded = *incoming; + ASSERT_TRUE(node.sendPacket(incoming, 0)); + transmitNext(); + ASSERT_EQ(1, manager.getOutboundTotal()); + const auto* retry = manager.getOutboundByIdx(0); + EXPECT_EQ(forwarded.path_len, retry->path_len); + EXPECT_EQ(0, memcmp(forwarded.path, retry->path, width * 2)); + transmitNext(); + auto echo = forwarded; + echo.setPathHashCount(1); + memcpy(echo.path, route + width * 2, width); + node.receivePacket(&echo); + expectIdle(); + + auto* last_hop = node.createRawData(payload.data(), payload.size()); + ASSERT_NE(nullptr, last_hop); + last_hop->header |= ROUTE_TYPE_DIRECT; + last_hop->setPathHashSizeAndCount(width, 1); + memcpy(last_hop->path, route, width); + ASSERT_NE(ACTION_RELEASE, node.receivePacket(last_hop)); + EXPECT_EQ(0, last_hop->getPathHashCount()); + ASSERT_TRUE(node.sendPacket(last_hop, 0)); + transmitNext(); + expectIdle(); + } + EXPECT_EQ(9U, radio.transmitted_crs.size()); // initial, retry, final relay per width +} + +TEST_P(TelemetryDirectRetryTest, EchoMustMatchPayloadRadioAndForwardProgress) { + auto* packet = queue(snapshot()); + ASSERT_NE(nullptr, packet); + const mesh::Packet original = *packet; + transmitNext(); + ASSERT_EQ(1, manager.getOutboundTotal()); + + auto wrong_payload = original; + wrong_payload.setPathHashCount(1); + wrong_payload.payload[wrong_payload.payload_len - 1] ^= 1; + node.receivePacket(&wrong_payload); + EXPECT_EQ(1, manager.getOutboundTotal()); + + auto wrong_radio = original; + wrong_radio.setPathHashCount(1); + wrong_radio.radio_profile = 1; + node.receivePacket(&wrong_radio); + EXPECT_EQ(1, manager.getOutboundTotal()); + + auto no_progress = original; + node.receivePacket(&no_progress); + EXPECT_EQ(1, manager.getOutboundTotal()); + + transmitNext(); + EXPECT_EQ((std::vector{5, 5}), radio.transmitted_crs); + auto valid_echo = original; + valid_echo.setPathHashCount(0); + node.receivePacket(&valid_echo); + expectIdle(); +} + +TEST_P(TelemetryDirectRetryTest, ZeroHopRemainsASingleTransmission) { + ASSERT_NE(nullptr, queue(snapshot(), 0)); + transmitNext(); + expectIdle(); + EXPECT_EQ((std::vector{5}), radio.transmitted_crs); +} + +TEST_P(TelemetryDirectRetryTest, DirectRetrySettingStillGatesTelemetry) { + node.direct_enabled = false; + ASSERT_NE(nullptr, queue(snapshot())); + transmitNext(); + expectIdle(); + EXPECT_EQ((std::vector{5}), radio.transmitted_crs); +} + +TEST_P(TelemetryDirectRetryTest, LargestSnapshotOfEachFamilyCanRetry) { + const char* magic = GetParam(); + const uint8_t maximum = magic[0] == 'N' ? 255 : magic[0] == 'I' ? 64 : 165; + auto* packet = queue(snapshot(maximum)); + ASSERT_NE(nullptr, packet); + mesh::Packet echo = *packet; + transmitNext(); + ASSERT_EQ(1, manager.getOutboundTotal()); + transmitNext(); + EXPECT_EQ((std::vector{5, 5}), radio.transmitted_crs); + echo.setPathHashCount(1); + node.receivePacket(&echo); + expectIdle(); +} + +TEST_P(TelemetryDirectRetryTest, MalformedAndUnrelatedCustomPayloadsDoNotRetry) { + const auto valid = snapshot(); + std::vector> invalid; + invalid.emplace_back(valid.begin(), valid.begin() + 3); + invalid.push_back(valid); + invalid.back().pop_back(); + invalid.push_back(valid); + invalid.back().push_back(0); + invalid.push_back(valid); + invalid.back()[16] = invalid.back()[17] = 0; + invalid.push_back(valid); + invalid.back()[3] = '2'; + invalid.push_back(valid); + invalid.back()[0] = 'X'; + invalid.push_back(valid); + invalid.back()[1] = 'X'; + invalid.push_back(valid); + invalid.back()[2] = 'X'; + const bool external = GetParam()[0] == 'I'; + invalid.push_back(valid); + invalid.back()[external ? 19 : 18] = 0; + invalid.back().resize(external ? 20 : 19); + if (external) { + invalid.push_back(valid); + invalid.back()[18] = 0; + invalid.push_back(valid); + invalid.back()[19] = 65; + } else if (GetParam()[0] == 'T') { + invalid.push_back(valid); + invalid.back()[18] = 166; + } + for (size_t i = 0; i < invalid.size(); ++i) { + SCOPED_TRACE(i); + ASSERT_NE(nullptr, queue(invalid[i])); + transmitNext(); + expectIdle(); + } + EXPECT_EQ(invalid.size(), radio.transmitted_crs.size()); +} + +INSTANTIATE_TEST_SUITE_P(TelemetryFamilies, TelemetryDirectRetryTest, + testing::Values("TTB1", "TVB1", "IVB1", "N1B1", "N2B1")); + static mesh::Packet makeOtaManifestFragment(uint8_t format_version) { mesh::Packet packet = makeFloodPacket(PAYLOAD_TYPE_OTA); packet.payload_len = 12;