diff --git a/docs/cli_commands.md b/docs/cli_commands.md index aead0b0a..19ca0154 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -2655,11 +2655,12 @@ set direct.retry.cr 20.0,12.0,6.0,2.0 - `get recent.repeater` - `get recent.repeater ` - `get recent.repeaters ` +- `get recent.repeaters search [page]` - `set recent.repeater [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 diff --git a/docs/halo_keymind_settings.md b/docs/halo_keymind_settings.md index 873b71c8..6e70f2a6 100644 --- a/docs/halo_keymind_settings.md +++ b/docs/halo_keymind_settings.md @@ -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 `, `set recent.repeater `, `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 `, `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 ` | `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=` for a per-channel hop override. | `get flood.channel.block`, `set flood.channel.block[.n] [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 diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index b0200517..4c8b1b29 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -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(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(); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index fd313542..e8b366e2 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -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; diff --git a/src/helpers/CLICommandUtils.h b/src/helpers/CLICommandUtils.h index d8825100..04b5140d 100644 --- a/src/helpers/CLICommandUtils.h +++ b/src/helpers/CLICommandUtils.h @@ -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(*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(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(age_seconds / 3600UL)); + } else if (age_seconds >= 60UL) { + snprintf(output, output_size, "%lum", + static_cast(age_seconds / 60UL)); + } else { + snprintf(output, output_size, "%lus", + static_cast(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(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((hi << 4) | lo); + } + query.search_prefix_len = static_cast(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; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index c93a0754..6ef0f764 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -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++ = ' '; diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 3745b99d..c3059ece 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -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) { diff --git a/src/helpers/SimpleMeshTables.h b/src/helpers/SimpleMeshTables.h index d8364620..f358fdfe 100644 --- a/src/helpers/SimpleMeshTables.h +++ b/src/helpers/SimpleMeshTables.h @@ -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 { diff --git a/test/test_cli_command_utils/test_cli_command_utils.cpp b/test/test_cli_command_utils/test_cli_command_utils.cpp index 68a47128..37c8773f 100644 --- a/test/test_cli_command_utils/test_cli_command_utils.cpp +++ b/test/test_cli_command_utils/test_cli_command_utils.cpp @@ -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] = ""; diff --git a/test/test_mesh_tables/test_simple_mesh_tables.cpp b/test/test_mesh_tables/test_simple_mesh_tables.cpp index 72c2ce08..7b7b7414 100644 --- a/test/test_mesh_tables/test_simple_mesh_tables.cpp +++ b/test/test_mesh_tables/test_simple_mesh_tables.cpp @@ -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);