Add recent repeater prefix search

This commit is contained in:
mikecarper committed 2026-08-05 17:54:52 -07:00
1 parent 6ddbaf3274
commit 81ff965c7c
10 files changed
+396 -108

No files matched your search

+10 -1
View File
@@ -2655,11 +2655,12 @@ set direct.retry.cr 20.0,12.0,6.0,2.0
- `get recent.repeater`
- `get recent.repeater <page>`
- `get recent.repeaters <page>`
- `get recent.repeaters search <prefix> [page]`
- `set recent.repeater <prefix> [snr_db]`
- `clear recent.repeater`
**Parameters:**
- `prefix`: Repeater path-hash prefix as hex.
- `prefix`: Repeater path-hash prefix as 2, 4, or 6 hex characters.
- `snr_db`: Optional SNR in dB. If omitted or invalid, defaults to `3.0`.
- `page`: 1-based result page.
@@ -2668,6 +2669,12 @@ set direct.retry.cr 20.0,12.0,6.0,2.0
**Output order:**
- `get recent.repeater` lists 3-byte prefixes first, then 2-byte prefixes, then 1-byte prefixes.
- Within each prefix length, entries are sorted from highest SNR to lowest SNR.
- `search` returns every overlapping path-hash entry. For example, searching
`860C` can return `86`, `860C`, and `860CCA`; it does not return a different
branch such as `86D0`.
- Search rows include the monotonic age of the entry's most recent recording,
compacted to a whole `s`, `m`, or `h` field. Search pages contain up to six
rows so the result remains within the remote CLI reply limit.
**SNR details:**
- Recent repeater SNR is stored internally in quarter-dB units.
@@ -2684,6 +2691,8 @@ set direct.retry.cr 20.0,12.0,6.0,2.0
```
get recent.repeater
get recent.repeater 2
get recent.repeaters search 86
get recent.repeaters search 860C page 2
set recent.repeater A1B2C3 8.5
set recent.repeater 71CE82 -3.25
set recent.repeater A1B2C3
+9 -5
View File
@@ -79,7 +79,7 @@ set flood.retry.ignore none
| `battery.alert` | Sends opt-in, region-scoped low-battery warnings to `#repeaters` after 30 minutes of uptime. | `get battery.alert`, `get battery.alert.region`, `set battery.alert on [region]`, `set battery.alert off` | `set battery.alert on sea` |
| `battery.alert.low` | Warning threshold percentage. Must be greater than `battery.alert.critical`. | `get battery.alert.low`, `set battery.alert.low <1-100>` | `set battery.alert.low 20` |
| `battery.alert.critical` | Critical threshold percentage. Critical and warning alerts use the same 12-hour resend cooldown. | `get battery.alert.critical`, `set battery.alert.critical <0-99>` | `set battery.alert.critical 10` |
| `recent.repeater` | Shows, seeds, or clears the recent repeater prefix/SNR table used by direct retry and bridge freshness checks. Entries older than 24 hours are removed by a three-hour sweep. | `get recent.repeater`, `get recent.repeater <page>`, `set recent.repeater <prefix> <snr_db>`, `clear recent.repeater` | `set recent.repeater A1B2C3 -8.5` |
| `recent.repeater` | Shows, searches, seeds, or clears the recent repeater prefix/SNR table used by direct retry and bridge freshness checks. Search results include the last-recorded age. Entries older than 24 hours are removed by a three-hour sweep. | `get recent.repeater [page]`, `get recent.repeaters search <2|4|6 hex> [page]`, `set recent.repeater <prefix> <snr_db>`, `clear recent.repeater` | `get recent.repeaters search A1B2` |
| `flood.channel.data` | Turns forwarding of flood `GRP_DATA` channel packets on or off. With the default `on`, `GRP_DATA` repeats normally even when `flood.channel.block.hops` is set. | `get flood.channel.data`, `set flood.channel.data on/off` | `set flood.channel.data off` |
| `flood.channel.data.hops` | Separate hop gate used only when `flood.channel.data` is `off`; `all` blocks `GRP_DATA` at any hop count, `1`-`7` repeats at that hop count or lower and blocks longer paths. | `get flood.channel.data.hops`, `set flood.channel.data.hops <all|1-7>` | `set flood.channel.data.hops 7` |
| `flood.channel.block` | Blocks selected flood `GRP_TXT`/`GRP_DATA` channels when the key validates the packet. New repeater block lists start with editable/deletable `#wardriving h=4`. Add `h=<all|1-7|default>` for a per-channel hop override. | `get flood.channel.block`, `set flood.channel.block[.n] <key|#channel> [name] [h=...]`, `del flood.channel.block[.n]` | `set flood.channel.block #wardriving h=4` |
@@ -160,8 +160,14 @@ get recent.repeater
get recent.repeater 2
get recent.repeaters 2
get recent.repeater page 3
get recent.repeaters search A1
get recent.repeaters search A1B2 page 2
```
Search matches overlapping 1-, 2-, and 3-byte path hashes. Each matching row
shows its stored SNR and a whole `s`, `m`, or `h` age measured from the most
recent recording. Search pages contain up to six rows.
Seed or correct a prefix:
```text
@@ -175,10 +181,8 @@ clear recent.repeater
```
Rows are sorted by prefix width, then SNR. A full direct retry failure lowers
the matching row by `0.25 dB`.
Serial CLI pages contain up to `128` rows. Remote LoRa CLI pages contain up to
`7` rows.
the matching row by `0.25 dB`. Unfiltered paged replies contain up to `10`
rows; an unpaged local serial query prints the full table.
## Direct Path Overrides
+48 -46
View File
@@ -2174,70 +2174,75 @@ static void formatLocalSnrX4(char* dest, size_t dest_len, int16_t snr_x4) {
}
}
static bool parseRecentRepeatersPageCommand(const char* command, int& page) {
if (strncmp(command, "get ", 4) != 0) {
return false;
}
const char* cursor = command + 4;
if (strncmp(cursor, "recent.repeater", 15) != 0) {
return false;
}
cursor += 15;
if (*cursor == 's') {
cursor++;
}
if (*cursor == 0) {
return false;
}
if (*cursor != ' ') {
return false;
}
while (*cursor == ' ') cursor++;
if (strncmp(cursor, "page", 4) == 0 && (cursor[4] == 0 || cursor[4] == ' ')) {
cursor += 4;
while (*cursor == ' ') cursor++;
}
page = 1;
if (*cursor) page = atoi(cursor);
if (page < 1) page = 1;
return true;
}
void MyMesh::formatRecentRepeatersReply(char *reply, int page) {
void MyMesh::formatRecentRepeatersReply(char *reply, int page,
const uint8_t* search_prefix,
uint8_t search_prefix_len) {
const SimpleMeshTables* tables = static_cast<const SimpleMeshTables*>(getTables());
if (tables == NULL) {
strcpy(reply, "Error: unsupported");
return;
}
int count = tables->getRecentRepeaterCount();
const bool is_search = search_prefix != NULL && search_prefix_len > 0;
int count = is_search
? tables->getRecentRepeaterMatchingCount(search_prefix, search_prefix_len)
: tables->getRecentRepeaterCount();
if (count <= 0) {
strcpy(reply, "> -none-");
if (is_search) {
char search_hex[MAX_ROUTE_HASH_BYTES * 2 + 1];
mesh::Utils::toHex(search_hex, search_prefix, search_prefix_len);
search_hex[search_prefix_len * 2] = 0;
snprintf(reply, 160, "> %s -none-", search_hex);
} else {
strcpy(reply, "> -none-");
}
return;
}
const int page_size = 10;
// Search rows include their compact recorded age. Six worst-case rows plus the
// page header still fit the 160-byte remote CLI reply buffer.
const int page_size = is_search ? 6 : 10;
int pages = (count + page_size - 1) / page_size;
if (page < 1) page = 1;
if (page > pages) page = pages;
int len = snprintf(reply, 160, "> %d/%d", page, pages);
int len;
if (is_search) {
char search_hex[MAX_ROUTE_HASH_BYTES * 2 + 1];
mesh::Utils::toHex(search_hex, search_prefix, search_prefix_len);
search_hex[search_prefix_len * 2] = 0;
len = snprintf(reply, 160, "> %s %d/%d (%d matches)",
search_hex, page, pages, count);
} else {
len = snprintf(reply, 160, "> %d/%d", page, pages);
}
int start = (page - 1) * page_size;
for (int i = 0; i < page_size && len < 150; i++) {
const SimpleMeshTables::RecentRepeaterInfo* info = tables->getRecentRepeaterBySortedIdx(start + i);
const SimpleMeshTables::RecentRepeaterInfo* info = is_search
? tables->getRecentRepeaterMatchingBySortedIdx(
search_prefix, search_prefix_len, start + i)
: tables->getRecentRepeaterBySortedIdx(start + i);
if (info == NULL) break;
char prefix[MAX_ROUTE_HASH_BYTES * 2 + 1];
char snr[12];
mesh::Utils::toHex(prefix, info->prefix, info->prefix_len);
prefix[info->prefix_len * 2] = 0;
formatLocalSnrX4(snr, sizeof(snr), info->snr_x4);
len += snprintf(&reply[len], 160 - len, "\n%s,%s%s",
prefix,
snr[0] == '-' ? "" : " ",
snr);
if (is_search) {
const uint32_t age_seconds =
(uint32_t)(millis() - info->last_heard_millis) / 1000UL;
char age[12];
mesh::cli::formatRecentRepeaterAge(age, sizeof(age), age_seconds);
len += snprintf(&reply[len], 160 - len, "\n%s,%s%s,%s",
prefix,
snr[0] == '-' ? "" : " ",
snr,
age);
} else {
len += snprintf(&reply[len], 160 - len, "\n%s,%s%s",
prefix,
snr[0] == '-' ? "" : " ",
snr);
}
}
}
@@ -8153,7 +8158,6 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
sender == NULL ? NULL : sender->id.pub_key);
#endif
char* reply_start = reply;
int recent_page = 1;
// Remote admin clients may include a line ending in the command payload.
// Normalize it here so exact-match commands such as `get outpath` behave the
@@ -8528,8 +8532,6 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
strcpy(reply, "Err - bad pubkey");
}
}
} else if (sender_timestamp == 0 && sender == NULL && parseRecentRepeatersPageCommand(command, recent_page)) {
formatRecentRepeatersReply(reply, recent_page);
} else if (sender_timestamp == 0 && sender == NULL
&& (strcmp(command, "get recent.repeater") == 0 || strcmp(command, "get recent.repeaters") == 0)) {
printRecentRepeatersSerial();
+3 -1
View File
@@ -782,7 +782,9 @@ public:
void formatRadioStatsReply(char *reply) override;
void formatRadioDiagReply(char *reply) override;
void formatPacketStatsReply(char *reply) override;
void formatRecentRepeatersReply(char *reply, int page) override;
void formatRecentRepeatersReply(char *reply, int page,
const uint8_t* search_prefix,
uint8_t search_prefix_len) override;
bool setRecentRepeater(const uint8_t* prefix, uint8_t prefix_len, int8_t snr_x4) override;
void clearRecentRepeaters() override;
void startRegionsLoad() override;
+117
View File
@@ -23,6 +23,123 @@ enum class NoArgCommandMatch : uint8_t {
HasArguments,
};
enum class RecentRepeaterGetMatch : uint8_t {
NoMatch = 0,
Valid,
Invalid,
};
struct RecentRepeaterGetQuery {
int page;
uint8_t search_prefix[3];
uint8_t search_prefix_len;
};
inline const char* skipRecentRepeaterSpaces(const char* text) {
while (text != nullptr && (*text == ' ' || *text == '\t')) text++;
return text;
}
inline int recentRepeaterHexNibble(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
inline bool parseRecentRepeaterPage(const char* text, int& page) {
text = skipRecentRepeaterSpaces(text);
if (text == nullptr || *text == 0) {
page = 1;
return true;
}
uint32_t parsed = 0;
bool saw_digit = false;
while (*text >= '0' && *text <= '9') {
saw_digit = true;
const uint8_t digit = static_cast<uint8_t>(*text++ - '0');
// The formatter clamps the requested page to the available page count.
// Saturating here avoids signed overflow from hostile remote CLI input.
if (parsed <= 65535UL) {
parsed = parsed * 10UL + digit;
if (parsed > 65535UL) parsed = 65535UL;
}
}
text = skipRecentRepeaterSpaces(text);
if (!saw_digit || *text != 0) return false;
page = parsed == 0 ? 1 : static_cast<int>(parsed);
return true;
}
inline void formatRecentRepeaterAge(char* output, size_t output_size,
uint32_t age_seconds) {
if (output == nullptr || output_size == 0) return;
if (age_seconds >= 3600UL) {
snprintf(output, output_size, "%luh",
static_cast<unsigned long>(age_seconds / 3600UL));
} else if (age_seconds >= 60UL) {
snprintf(output, output_size, "%lum",
static_cast<unsigned long>(age_seconds / 60UL));
} else {
snprintf(output, output_size, "%lus",
static_cast<unsigned long>(age_seconds));
}
}
// Parse the portion after `get `. Besides the existing list/page forms, this
// accepts `recent.repeaters search <2|4|6 hex> [page [N]|N]`.
inline RecentRepeaterGetMatch parseRecentRepeaterGet(
const char* config, RecentRepeaterGetQuery& query) {
query.page = 1;
memset(query.search_prefix, 0, sizeof(query.search_prefix));
query.search_prefix_len = 0;
if (config == nullptr || strncmp(config, "recent.repeater", 15) != 0) {
return RecentRepeaterGetMatch::NoMatch;
}
const char* cursor = config + 15;
if (*cursor == 's') cursor++;
if (*cursor != 0 && *cursor != ' ' && *cursor != '\t') {
return RecentRepeaterGetMatch::NoMatch;
}
cursor = skipRecentRepeaterSpaces(cursor);
if (*cursor == 0) return RecentRepeaterGetMatch::Valid;
if (strncmp(cursor, "search", 6) == 0
&& (cursor[6] == 0 || cursor[6] == ' ' || cursor[6] == '\t')) {
cursor = skipRecentRepeaterSpaces(cursor + 6);
const char* hex = cursor;
while (*cursor != 0 && *cursor != ' ' && *cursor != '\t') cursor++;
const size_t hex_len = static_cast<size_t>(cursor - hex);
if (hex_len != 2 && hex_len != 4 && hex_len != 6) {
return RecentRepeaterGetMatch::Invalid;
}
for (size_t i = 0; i < hex_len; i += 2) {
const int hi = recentRepeaterHexNibble(hex[i]);
const int lo = recentRepeaterHexNibble(hex[i + 1]);
if (hi < 0 || lo < 0) return RecentRepeaterGetMatch::Invalid;
query.search_prefix[i / 2] = static_cast<uint8_t>((hi << 4) | lo);
}
query.search_prefix_len = static_cast<uint8_t>(hex_len / 2);
cursor = skipRecentRepeaterSpaces(cursor);
if (strncmp(cursor, "page", 4) == 0
&& (cursor[4] == 0 || cursor[4] == ' ' || cursor[4] == '\t')) {
cursor = skipRecentRepeaterSpaces(cursor + 4);
}
return parseRecentRepeaterPage(cursor, query.page)
? RecentRepeaterGetMatch::Valid : RecentRepeaterGetMatch::Invalid;
}
if (strncmp(cursor, "page", 4) == 0
&& (cursor[4] == 0 || cursor[4] == ' ' || cursor[4] == '\t')) {
cursor = skipRecentRepeaterSpaces(cursor + 4);
}
return parseRecentRepeaterPage(cursor, query.page)
? RecentRepeaterGetMatch::Valid : RecentRepeaterGetMatch::Invalid;
}
inline StandaloneWiFiKey classifyStandaloneWiFiGet(const char* config) {
if (config == nullptr) return StandaloneWiFiKey::None;
if (strcmp(config, "wifi.ssid") == 0) return StandaloneWiFiKey::SSID;
+10 -28
View File
@@ -158,31 +158,6 @@ static bool otaCommandNeedsTempRadio(const char* command) {
}
#endif
static bool parseRecentRepeaterGet(const char* config, int& page) {
if (strncmp(config, "recent.repeater", 15) != 0) {
return false;
}
const char* cursor = &config[15];
if (*cursor == 's') {
cursor++;
}
if (*cursor != 0 && *cursor != ' ') {
return false;
}
while (*cursor == ' ') cursor++;
if (strncmp(cursor, "page", 4) == 0 && (cursor[4] == 0 || cursor[4] == ' ')) {
cursor += 4;
while (*cursor == ' ') cursor++;
}
page = 1;
if (*cursor) page = _atoi(cursor);
if (page < 1) page = 1;
return true;
}
static bool isValidName(const char *n) {
while (*n) {
if (*n == '[' || *n == ']' || *n == '/' || *n == '\\' || *n == ':' || *n == ',' || *n == '?' || *n == '*') return false;
@@ -4506,7 +4481,9 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
strcpy(reply, "Error, retry configuration unsupported on this role");
return;
}
int recent_page = 1;
mesh::cli::RecentRepeaterGetQuery recent_query;
const mesh::cli::RecentRepeaterGetMatch recent_query_match =
mesh::cli::parseRecentRepeaterGet(config, recent_query);
if (memcmp(config, "dutycycle", 9) == 0) {
float dc = 100.0f / (_prefs->airtime_factor + 1.0f);
int dc_int = (int)dc;
@@ -4677,8 +4654,13 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
formatSnrDbX4(cr8, sizeof(cr8), _prefs->direct_retry_cr8_snr_x4);
sprintf(reply, "> %s,%s,%s,%s", cr4, cr5, cr7, cr8);
}
} else if (parseRecentRepeaterGet(config, recent_page)) {
_callbacks->formatRecentRepeatersReply(reply, recent_page);
} else if (recent_query_match == mesh::cli::RecentRepeaterGetMatch::Valid) {
_callbacks->formatRecentRepeatersReply(
reply, recent_query.page,
recent_query.search_prefix_len == 0 ? NULL : recent_query.search_prefix,
recent_query.search_prefix_len);
} else if (recent_query_match == mesh::cli::RecentRepeaterGetMatch::Invalid) {
strcpy(reply, "Error, use: get recent.repeaters [page] | search <2|4|6 hex> [page]");
} else if (memcmp(config, "owner.info", 10) == 0) {
*reply++ = '>';
*reply++ = ' ';
+5 -1
View File
@@ -279,8 +279,12 @@ public:
virtual void formatRadioStatsReply(char *reply) = 0;
virtual void formatRadioDiagReply(char *reply) { strcpy(reply, "Not supported"); }
virtual void formatPacketStatsReply(char *reply) = 0;
virtual void formatRecentRepeatersReply(char *reply, int page) {
virtual void formatRecentRepeatersReply(char *reply, int page,
const uint8_t* search_prefix = NULL,
uint8_t search_prefix_len = 0) {
(void)page;
(void)search_prefix;
(void)search_prefix_len;
if (reply != NULL) reply[0] = 0;
}
virtual bool setRecentRepeater(const uint8_t* prefix, uint8_t prefix_len, int8_t snr_x4) {
+65 -26
View File
@@ -189,6 +189,50 @@ private:
return a_idx < b_idx;
}
bool recentRepeaterMatchesFilter(const RecentRepeaterInfo& info,
const uint8_t* search_prefix,
uint8_t search_prefix_len) const {
if (search_prefix_len == 0) return true;
return search_prefix != NULL && search_prefix_len <= MAX_ROUTE_HASH_BYTES
&& routeHashPrefixesOverlap(info.prefix, info.prefix_len,
search_prefix, search_prefix_len);
}
const RecentRepeaterInfo* getRecentRepeaterBySortedIdxFiltered(
int idx_wanted, const uint8_t* search_prefix,
uint8_t search_prefix_len) const {
if (_max_recent_repeaters == 0 || idx_wanted < 0
|| search_prefix_len > MAX_ROUTE_HASH_BYTES
|| (search_prefix_len > 0 && search_prefix == NULL)) {
return NULL;
}
const RecentRepeaterInfo* last = NULL;
int last_idx = -1;
for (int rank = 0; rank <= idx_wanted; rank++) {
const RecentRepeaterInfo* best = NULL;
int best_idx = -1;
for (int i = 0; i < _recent_repeater_count; i++) {
const RecentRepeaterInfo* info = &_recent_repeaters[i];
if (!recentRepeaterMatchesFilter(*info, search_prefix,
search_prefix_len)) {
continue;
}
if (last != NULL && !recentRepeaterComesBefore(*last, last_idx, *info, i)) {
continue;
}
if (best == NULL || recentRepeaterComesBefore(*info, i, *best, best_idx)) {
best = info;
best_idx = i;
}
}
if (best == NULL) return NULL;
last = best;
last_idx = best_idx;
}
return last;
}
void recordRecentRepeater(const mesh::Packet* packet) {
if (_max_recent_repeaters == 0) {
return;
@@ -419,35 +463,30 @@ public:
return _recent_repeater_count;
}
const RecentRepeaterInfo* getRecentRepeaterBySortedIdx(int idx_wanted) const {
if (_max_recent_repeaters == 0) {
return NULL;
return getRecentRepeaterBySortedIdxFiltered(idx_wanted, NULL, 0);
}
int getRecentRepeaterMatchingCount(const uint8_t* search_prefix,
uint8_t search_prefix_len) const {
if (_max_recent_repeaters == 0 || search_prefix == NULL
|| search_prefix_len == 0
|| search_prefix_len > MAX_ROUTE_HASH_BYTES) {
return 0;
}
if (idx_wanted < 0) {
return NULL;
}
const RecentRepeaterInfo* last = NULL;
int last_idx = -1;
for (int rank = 0; rank <= idx_wanted; rank++) {
const RecentRepeaterInfo* best = NULL;
int best_idx = -1;
for (int i = 0; i < _recent_repeater_count; i++) {
const RecentRepeaterInfo* info = &_recent_repeaters[i];
if (last != NULL && !recentRepeaterComesBefore(*last, last_idx, *info, i)) {
continue;
}
if (best == NULL || recentRepeaterComesBefore(*info, i, *best, best_idx)) {
best = info;
best_idx = i;
}
int count = 0;
for (int i = 0; i < _recent_repeater_count; i++) {
if (recentRepeaterMatchesFilter(_recent_repeaters[i], search_prefix,
search_prefix_len)) {
count++;
}
if (best == NULL) {
return NULL;
}
last = best;
last_idx = best_idx;
}
return last;
return count;
}
const RecentRepeaterInfo* getRecentRepeaterMatchingBySortedIdx(
const uint8_t* search_prefix, uint8_t search_prefix_len,
int idx_wanted) const {
if (search_prefix == NULL || search_prefix_len == 0) return NULL;
return getRecentRepeaterBySortedIdxFiltered(
idx_wanted, search_prefix, search_prefix_len);
}
const RecentRepeaterInfo* findRecentRepeaterByHash(const uint8_t* hash, uint8_t hash_len) const {
@@ -151,6 +151,96 @@ TEST(CLICommandUtils, MatchesDiscoverNeighborsWithoutPrefixCollisions) {
"discover.neighbors.extra", "discover.neighbors"));
}
TEST(CLICommandUtils, ParsesRecentRepeaterListAndPageQueries) {
using mesh::cli::RecentRepeaterGetMatch;
mesh::cli::RecentRepeaterGetQuery query;
EXPECT_EQ(RecentRepeaterGetMatch::Valid,
mesh::cli::parseRecentRepeaterGet("recent.repeater", query));
EXPECT_EQ(1, query.page);
EXPECT_EQ(0, query.search_prefix_len);
EXPECT_EQ(RecentRepeaterGetMatch::Valid,
mesh::cli::parseRecentRepeaterGet(
"recent.repeaters page 17", query));
EXPECT_EQ(17, query.page);
EXPECT_EQ(0, query.search_prefix_len);
EXPECT_EQ(RecentRepeaterGetMatch::Valid,
mesh::cli::parseRecentRepeaterGet("recent.repeaters 0", query));
EXPECT_EQ(1, query.page);
}
TEST(CLICommandUtils, ParsesRecentRepeaterHexSearchesAndOptionalPages) {
using mesh::cli::RecentRepeaterGetMatch;
mesh::cli::RecentRepeaterGetQuery query;
EXPECT_EQ(RecentRepeaterGetMatch::Valid,
mesh::cli::parseRecentRepeaterGet(
"recent.repeaters search 86", query));
ASSERT_EQ(1, query.search_prefix_len);
EXPECT_EQ(0x86, query.search_prefix[0]);
EXPECT_EQ(1, query.page);
EXPECT_EQ(RecentRepeaterGetMatch::Valid,
mesh::cli::parseRecentRepeaterGet(
"recent.repeaters search 860c 2", query));
ASSERT_EQ(2, query.search_prefix_len);
EXPECT_EQ(0x86, query.search_prefix[0]);
EXPECT_EQ(0x0c, query.search_prefix[1]);
EXPECT_EQ(2, query.page);
EXPECT_EQ(RecentRepeaterGetMatch::Valid,
mesh::cli::parseRecentRepeaterGet(
"recent.repeater search 860CCA page 3", query));
ASSERT_EQ(3, query.search_prefix_len);
EXPECT_EQ(0x86, query.search_prefix[0]);
EXPECT_EQ(0x0c, query.search_prefix[1]);
EXPECT_EQ(0xca, query.search_prefix[2]);
EXPECT_EQ(3, query.page);
}
TEST(CLICommandUtils, RejectsMalformedRecentRepeaterSearches) {
using mesh::cli::RecentRepeaterGetMatch;
mesh::cli::RecentRepeaterGetQuery query;
EXPECT_EQ(RecentRepeaterGetMatch::Invalid,
mesh::cli::parseRecentRepeaterGet(
"recent.repeaters search", query));
EXPECT_EQ(RecentRepeaterGetMatch::Invalid,
mesh::cli::parseRecentRepeaterGet(
"recent.repeaters search 860", query));
EXPECT_EQ(RecentRepeaterGetMatch::Invalid,
mesh::cli::parseRecentRepeaterGet(
"recent.repeaters search 8G", query));
EXPECT_EQ(RecentRepeaterGetMatch::Invalid,
mesh::cli::parseRecentRepeaterGet(
"recent.repeaters search 860C extra", query));
EXPECT_EQ(RecentRepeaterGetMatch::Invalid,
mesh::cli::parseRecentRepeaterGet(
"recent.repeaters nope", query));
EXPECT_EQ(RecentRepeaterGetMatch::NoMatch,
mesh::cli::parseRecentRepeaterGet(
"recent.repeaters.extra", query));
}
TEST(CLICommandUtils, FormatsRecentRepeaterAgeWithCompactSuffix) {
char age[12];
mesh::cli::formatRecentRepeaterAge(age, sizeof(age), 0);
EXPECT_STREQ("0s", age);
mesh::cli::formatRecentRepeaterAge(age, sizeof(age), 59);
EXPECT_STREQ("59s", age);
mesh::cli::formatRecentRepeaterAge(age, sizeof(age), 60);
EXPECT_STREQ("1m", age);
mesh::cli::formatRecentRepeaterAge(age, sizeof(age), 3599);
EXPECT_STREQ("59m", age);
mesh::cli::formatRecentRepeaterAge(age, sizeof(age), 3600);
EXPECT_STREQ("1h", age);
mesh::cli::formatRecentRepeaterAge(age, sizeof(age), 26UL * 3600UL);
EXPECT_STREQ("26h", age);
}
TEST(CLICommandUtils, FormatsUsefulUnknownSettingErrors) {
char reply[64] = "";
@@ -233,6 +233,45 @@ TEST(RouteHashPrefixes, MatchesSharedOneTwoOrThreeBytes) {
EXPECT_FALSE(routeHashPrefixesOverlap(configured, 3, mismatch, 2));
}
TEST(SimpleMeshTables, RecentRepeaterSearchReturnsAllOverlappingPrefixesInDisplayOrder) {
SimpleMeshTables::RecentRepeaterInfo storage[6];
SimpleMeshTables t(storage, 6);
const uint8_t one_byte[] = {0x86};
const uint8_t two_bytes[] = {0x86, 0x0c};
const uint8_t three_bytes[] = {0x86, 0x0c, 0xca};
const uint8_t other_branch[] = {0x86, 0xd0};
const uint8_t unrelated[] = {0x71, 0xce, 0x82};
ASSERT_TRUE(t.setRecentRepeater(one_byte, 1, 20));
ASSERT_TRUE(t.setRecentRepeater(two_bytes, 2, 4));
ASSERT_TRUE(t.setRecentRepeater(three_bytes, 3, 8));
ASSERT_TRUE(t.setRecentRepeater(other_branch, 2, 12));
ASSERT_TRUE(t.setRecentRepeater(unrelated, 3, 16));
EXPECT_EQ(3, t.getRecentRepeaterMatchingCount(two_bytes, 2));
const auto* first = t.getRecentRepeaterMatchingBySortedIdx(two_bytes, 2, 0);
const auto* second = t.getRecentRepeaterMatchingBySortedIdx(two_bytes, 2, 1);
const auto* third = t.getRecentRepeaterMatchingBySortedIdx(two_bytes, 2, 2);
ASSERT_NE(nullptr, first);
ASSERT_NE(nullptr, second);
ASSERT_NE(nullptr, third);
EXPECT_EQ(3, first->prefix_len);
EXPECT_EQ(0, memcmp(three_bytes, first->prefix, 3));
EXPECT_EQ(2, second->prefix_len);
EXPECT_EQ(0, memcmp(two_bytes, second->prefix, 2));
EXPECT_EQ(1, third->prefix_len);
EXPECT_EQ(0, memcmp(one_byte, third->prefix, 1));
EXPECT_EQ(nullptr,
t.getRecentRepeaterMatchingBySortedIdx(two_bytes, 2, 3));
// A one-byte search also includes both two-byte branches sharing it.
EXPECT_EQ(4, t.getRecentRepeaterMatchingCount(one_byte, 1));
const auto* branch =
t.getRecentRepeaterMatchingBySortedIdx(one_byte, 1, 1);
ASSERT_NE(nullptr, branch);
EXPECT_EQ(0, memcmp(other_branch, branch->prefix, 2));
}
TEST(SimpleMeshTables, ShortFailurePrefixUpdatesOverlappingLongEntry) {
SimpleMeshTables::RecentRepeaterInfo storage[MAX_RECENT_REPEATERS];
SimpleMeshTables t(storage, MAX_RECENT_REPEATERS);