Enable direct-route echo retries for telemetry snapshots

This commit is contained in:
mikecarper committed 2026-10-23 15:00:42 -07:00
1 parent 437afbdda1
commit ea4b53ceee
4 files changed
+349 -87

No files matched your search

+16 -5
View File
@@ -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.
+9
View File
@@ -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:
+54 -82
View File
@@ -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*>(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*>(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++;
}
+270
View File
@@ -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<const char*> {
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<uint8_t> 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<uint8_t> 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<uint8_t>& 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<uint8_t>{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<uint8_t>{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<uint8_t>{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<uint8_t>{5}), radio.transmitted_crs);
}
TEST_P(TelemetryDirectRetryTest, DirectRetrySettingStillGatesTelemetry) {
node.direct_enabled = false;
ASSERT_NE(nullptr, queue(snapshot()));
transmitNext();
expectIdle();
EXPECT_EQ((std::vector<uint8_t>{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<uint8_t>{5, 5}), radio.transmitted_crs);
echo.setPathHashCount(1);
node.receivePacket(&echo);
expectIdle();
}
TEST_P(TelemetryDirectRetryTest, MalformedAndUnrelatedCustomPayloadsDoNotRetry) {
const auto valid = snapshot();
std::vector<std::vector<uint8_t>> 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;