diff --git a/build.sh b/build.sh index 82902578..49ba38ad 100755 --- a/build.sh +++ b/build.sh @@ -88,6 +88,7 @@ Commands: build-full-esp32-logging-firmwares: Build only feature-complete ESP32 profiles with up to 254 neighbors, logging, MQTT disabled, LoRa OTA, and expanded dual-OTA partitions. build-matching-firmwares : Build all firmwares for build targets containing the string given for . build-companion-firmwares: Build all companion firmwares for all build targets. + build-full-companion-firmwares: Build all full Companion firmwares for supported ESP32 and nRF52 targets. build-repeater-firmwares: Build all repeater firmwares with 254 neighbors, except DRAM-limited targets that retain 50. build-room-server-firmwares: Build all chat room server firmwares for all build targets. build-sensor-firmwares: Build all sensor firmwares for all build targets. @@ -127,6 +128,9 @@ $ bash build.sh build-full-esp32-logging-firmwares Build all companion firmwares $ bash build.sh build-companion-firmwares +Build all full Companion firmwares +$ bash build.sh build-full-companion-firmwares + Build all repeater firmwares $ bash build.sh build-repeater-firmwares @@ -475,6 +479,7 @@ prompt_for_build_mode() { "Build all sensor firmwares" "Build only FULL ESP32 MQTT firmwares (all features, MQTT, and LoRa OTA)" "Build only FULL ESP32 logging firmwares (all features, logging, no MQTT, and LoRa OTA)" + "Build all full Companion firmwares (all available transports and host-backed LoRa OTA)" ) echo "No command provided. Select a build action:" @@ -525,6 +530,10 @@ prompt_for_build_mode() { SELECTED_COMMAND_ARGS=(build-full-esp32-logging-firmwares) return 0 ;; + 10) + SELECTED_COMMAND_ARGS=(build-full-companion-firmwares) + return 0 + ;; esac done } @@ -2690,6 +2699,17 @@ resolve_companion_firmwares() { get_pio_envs_for_variant_role companion } +resolve_full_companion_firmwares() { + local env_name + + for env_name in "${SUPPORTED_PIO_ENVS[@]}"; do + if is_supported_build_env "$env_name" \ + && is_companion_radio_full_target "$env_name"; then + printf '%s\n' "$env_name" + fi + done +} + resolve_repeater_firmwares() { get_pio_envs_for_variant_role repeater } @@ -2734,6 +2754,9 @@ get_bulk_build_resolver_name() { build-companion-firmwares) echo "resolve_companion_firmwares" ;; + build-full-companion-firmwares) + echo "resolve_full_companion_firmwares" + ;; build-repeater-firmwares) echo "resolve_repeater_firmwares" ;; diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index 09aef58d..0dc54125 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -39,6 +39,14 @@ bash build.sh build-firmware RAK_4631_companion_radio_full \ --firmware-version v1.17.0 ``` +To build every available full Companion target, select the corresponding +interactive menu item or run: + +```bash +bash build.sh build-full-companion-firmwares \ + --firmware-version v1.17.0 +``` + Artifacts are written to `out/` by default. On 4 MB ESP32 boards, the full target uses a single 3 MB application @@ -105,16 +113,20 @@ The terminal supports Companion chat commands, including `channels`, channels channel #rgdata Hello from Eugene 👋 to Hilltop Repeater +path A1B2C3,D4E5F6 login my-admin-password cmd ver trace ``` -The `to` command selects the remote-administration target. Login passwords are -masked during entry and limited by the radio protocol to 15 UTF-8 bytes. Wait -for the asynchronous login result before using `cmd`; command replies appear -as `CLI -> from `. Remote ACL permissions determine which commands the -target accepts. +The `to` command selects the remote-administration target. `path` shows its +saved outgoing route; `path direct`, `path clear`, or a comma-separated list +such as `path A1B2C3,D4E5F6` changes the route used by subsequent `login`, +`send`, and `cmd` commands. Every hop must use the same 2-, 4-, or 6-digit +hexadecimal width. Login passwords are masked during entry and limited by the +radio protocol to 15 UTF-8 bytes. Wait for the asynchronous login result before +using `cmd`; command replies appear as `CLI -> from `. Remote ACL +permissions determine which commands the target accepts. With no argument, `trace` uses the current `to` recipient. A name-prefix argument traces that contact directly without changing the current recipient. diff --git a/docs/terminal_chat_cli.md b/docs/terminal_chat_cli.md index 2fc642e3..836df6d5 100644 --- a/docs/terminal_chat_cli.md +++ b/docs/terminal_chat_cli.md @@ -132,13 +132,45 @@ to {name-prefix} ``` Sets the recipient to the _first_ matching contact (in 'list') by the name prefix. (ie. you don't have to type whole name) +``` +path +``` +Shows the saved outgoing path for the current `to` recipient. This command and +all path changes require a recipient to be selected first. + +``` +path direct +path clear +path {hop-hash[,hop-hash...]} +``` +Sets the outgoing path used by subsequent `login`, `send`, and `cmd` commands. +`direct` selects a zero-hop route. `clear` forgets the saved route, causing the +next operation to use flood routing and allowing normal path discovery to +learn a replacement. + +Explicit paths use comma-separated hop hashes. Each hop must contain exactly +2, 4, or 6 hexadecimal digits, and every hop in one path must use the same +width. Spaces around commas and hexadecimal letter case do not matter. The +setting is saved with the selected contact. + +For example: + +```text +to Hilltop Repeater +path A1B2C3,D4E5F6 +path +login my-admin-password +``` + ``` login {admin-password} ``` Sends a remote login request to the current recipient. Select a repeater, room, or other remotely managed node with `to {name-prefix}` first. The password is masked with `*` while it is entered and must be 1-15 UTF-8 bytes; -longer passwords are rejected instead of truncated. +longer passwords are rejected instead of truncated. Login uses the route shown +by `path`: a known or explicitly set route is direct, while an unknown route is +flooded. Login results arrive asynchronously. A successful modern response displays the remote ACL permissions byte and server protocol level. A wrong password, @@ -219,7 +251,8 @@ eventually report a timeout. ``` reset path ``` -Resets the path to current recipient, for new path discovery. +Resets the path to current recipient, for new path discovery. This is retained +as an alias for `path clear`. ``` public {text} diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 0376c3fb..8f92fc4a 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -418,7 +418,8 @@ static bool deserializeContactRecord( memcpy(&c.gps_lon, &in[offset], 4); c.id = mesh::Identity(pub_key); c.shared_secret_valid = false; - return c.out_path_len == OUT_PATH_UNKNOWN || c.out_path_len <= MAX_PATH_SIZE; + return c.out_path_len == OUT_PATH_UNKNOWN + || mesh::Packet::isValidPathLen(c.out_path_len); } #if defined(NRF52_PLATFORM) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f34641a4..8b79c47f 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -3463,6 +3463,80 @@ ContactInfo* MyMesh::getTerminalRecipient() { return recipient; } +void MyMesh::printTerminalPath(const ContactInfo& recipient) { + Serial.printf(" Path to %s: ", recipient.name); + if (recipient.out_path_len == OUT_PATH_UNKNOWN) { + Serial.print("unknown (next login uses FLOOD)\r\n"); + return; + } + if (!mesh::Packet::isValidPathLen(recipient.out_path_len)) { + Serial.print("invalid\r\n"); + return; + } + + const uint8_t hash_size = (recipient.out_path_len >> 6) + 1; + const uint8_t hop_count = recipient.out_path_len & 63; + if (hop_count == 0) { + Serial.print("direct (zero hop; login uses DIRECT)\r\n"); + return; + } + + for (uint8_t hop = 0; hop < hop_count; hop++) { + if (hop != 0) Serial.print(','); + mesh::Utils::printHex(Serial, + &recipient.out_path[(size_t)hop * hash_size], + hash_size); + } + Serial.printf(" (%u %s, %u-byte hashes; login uses DIRECT)\r\n", + (unsigned)hop_count, hop_count == 1 ? "hop" : "hops", + (unsigned)hash_size); +} + +void MyMesh::handleTerminalPath(ContactInfo& recipient, + const char* path_spec) { + if (path_spec == NULL) { + printTerminalPath(recipient); + return; + } + + mesh::cli::TerminalPath path; + const mesh::cli::TerminalPathParseResult parsed = + mesh::cli::parseTerminalPath(path_spec, _terminal_tmp_buf, + sizeof(recipient.out_path), 63, path); + switch (parsed) { + case mesh::cli::TerminalPathParseResult::Valid: + break; + case mesh::cli::TerminalPathParseResult::Missing: + Serial.print(" ERROR: use path \r\n"); + return; + case mesh::cli::TerminalPathParseResult::InvalidPrefix: + Serial.print(" ERROR: each path hop must be 2, 4, or 6 hex digits\r\n"); + return; + case mesh::cli::TerminalPathParseResult::MixedPrefixSize: + Serial.print(" ERROR: all path hops must use the same width\r\n"); + return; + case mesh::cli::TerminalPathParseResult::InvalidSeparator: + Serial.print(" ERROR: separate path hops with commas\r\n"); + return; + case mesh::cli::TerminalPathParseResult::TooManyHops: + Serial.print(" ERROR: path must contain at most 63 hops\r\n"); + return; + case mesh::cli::TerminalPathParseResult::RouteTooLong: + Serial.print(" ERROR: path is too long\r\n"); + return; + } + + memset(recipient.out_path, 0, sizeof(recipient.out_path)); + if (path.mode == mesh::cli::TerminalPathMode::Clear) { + recipient.out_path_len = OUT_PATH_UNKNOWN; + } else { + recipient.out_path_len = mesh::Packet::copyPath( + recipient.out_path, _terminal_tmp_buf, path.encoded_len); + } + scheduleContactWrite(recipient); + printTerminalPath(recipient); +} + void MyMesh::rememberTerminalAck(ContactInfo& recipient, const char* text, uint32_t message_timestamp, uint32_t expected_ack, uint32_t est_timeout, @@ -3818,6 +3892,9 @@ void MyMesh::handleTerminalCommand(char* command) { const mesh::cli::TerminalArgumentCommandMatch command_match = mesh::cli::parseTerminalArgumentCommand(command, "cmd", remote_command); + const char* path_spec = NULL; + const mesh::cli::TerminalArgumentCommandMatch path_match = + mesh::cli::parseTerminalArgumentCommand(command, "path", path_spec); if (strcmp(command, "channels") == 0) { listTerminalChannels(); @@ -3924,6 +4001,17 @@ void MyMesh::handleTerminalCommand(char* command) { } else { Serial.print(" No recipient selected\r\n"); } + } else if (path_match + != mesh::cli::TerminalArgumentCommandMatch::NoMatch) { + ContactInfo* recipient = getTerminalRecipient(); + if (recipient == NULL) { + Serial.print(" ERROR: no recipient selected (use 'to' first)\r\n"); + } else { + handleTerminalPath( + *recipient, + path_match == mesh::cli::TerminalArgumentCommandMatch::Valid + ? path_spec : NULL); + } } else if (login_match != mesh::cli::TerminalArgumentCommandMatch::NoMatch) { ContactInfo* recipient = getTerminalRecipient(); @@ -4040,6 +4128,7 @@ void MyMesh::handleTerminalCommand(char* command) { Serial.print(" time \r\n"); Serial.print(" list [n]\r\n"); Serial.print(" to [recipient name or prefix]\r\n"); + Serial.print(" path [direct|clear|hop[,hop...]]\r\n"); Serial.print(" send \r\n"); Serial.print(" login \r\n"); Serial.print(" cmd \r\n"); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index bed4360a..eef526d5 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -274,6 +274,8 @@ private: void checkSerialInterface(); #ifdef ENABLE_USB_INTERFACE ContactInfo* getTerminalRecipient(); + void printTerminalPath(const ContactInfo& recipient); + void handleTerminalPath(ContactInfo& recipient, const char* path_spec); void importTerminalCard(char* command); void listTerminalChannels(); void sendTerminalChannelMessage(ChannelDetails& channel, const char* text); diff --git a/src/helpers/CLICommandUtils.h b/src/helpers/CLICommandUtils.h index fddab1b3..869ecbf8 100644 --- a/src/helpers/CLICommandUtils.h +++ b/src/helpers/CLICommandUtils.h @@ -42,6 +42,22 @@ enum class TerminalArgumentCommandMatch : uint8_t { MissingArgument, }; +enum class TerminalPathMode : uint8_t { + Explicit = 0, + Direct, + Clear, +}; + +enum class TerminalPathParseResult : uint8_t { + Valid = 0, + Missing, + InvalidPrefix, + MixedPrefixSize, + InvalidSeparator, + TooManyHops, + RouteTooLong, +}; + struct RecentRepeaterGetQuery { int page; uint8_t search_prefix[3]; @@ -54,6 +70,14 @@ struct TerminalChannelMessage { const char* text; }; +struct TerminalPath { + TerminalPathMode mode; + uint8_t encoded_len; + uint8_t hash_size; + uint8_t hop_count; + size_t byte_len; +}; + inline const char* skipRecentRepeaterSpaces(const char* text) { while (text != nullptr && (*text == ' ' || *text == '\t')) text++; return text; @@ -158,6 +182,99 @@ inline int recentRepeaterHexNibble(char c) { return -1; } +inline bool terminalPathKeywordMatches(const char* text, + const char* keyword) { + const size_t keyword_len = strlen(keyword); + if (strncmp(text, keyword, keyword_len) != 0) return false; + text = skipRecentRepeaterSpaces(text + keyword_len); + return *text == 0; +} + +// Parse: direct | clear | [, ...] +// Each explicit hop is a one-, two-, or three-byte hexadecimal prefix. All +// hops must use the same width because that width is encoded once for the +// complete MeshCore direct path. +inline TerminalPathParseResult parseTerminalPath( + const char* input, uint8_t* output, size_t output_capacity, + uint8_t max_hops, TerminalPath& result) { + result.mode = TerminalPathMode::Explicit; + result.encoded_len = 0; + result.hash_size = 0; + result.hop_count = 0; + result.byte_len = 0; + + input = skipRecentRepeaterSpaces(input); + if (input == nullptr || *input == 0) { + return TerminalPathParseResult::Missing; + } + if (terminalPathKeywordMatches(input, "direct")) { + result.mode = TerminalPathMode::Direct; + return TerminalPathParseResult::Valid; + } + if (terminalPathKeywordMatches(input, "clear")) { + result.mode = TerminalPathMode::Clear; + return TerminalPathParseResult::Valid; + } + + uint8_t hash_size = 0; + uint8_t hop_count = 0; + size_t offset = 0; + while (*input != 0) { + input = skipRecentRepeaterSpaces(input); + const char* token = input; + size_t token_len = 0; + while (input[token_len] != 0 && input[token_len] != ',' + && input[token_len] != ' ' && input[token_len] != '\t') { + token_len++; + } + if (token_len != 2 && token_len != 4 && token_len != 6) { + return TerminalPathParseResult::InvalidPrefix; + } + + const uint8_t token_hash_size = static_cast(token_len / 2); + if (hash_size == 0) { + hash_size = token_hash_size; + result.hash_size = hash_size; + } else if (token_hash_size != hash_size) { + return TerminalPathParseResult::MixedPrefixSize; + } + if (hop_count >= max_hops) { + return TerminalPathParseResult::TooManyHops; + } + if (output == nullptr || offset + hash_size > output_capacity) { + return TerminalPathParseResult::RouteTooLong; + } + + for (uint8_t i = 0; i < hash_size; i++) { + const int high = recentRepeaterHexNibble(token[i * 2]); + const int low = recentRepeaterHexNibble(token[i * 2 + 1]); + if (high < 0 || low < 0) { + return TerminalPathParseResult::InvalidPrefix; + } + output[offset++] = static_cast((high << 4) | low); + } + hop_count++; + input += token_len; + input = skipRecentRepeaterSpaces(input); + if (*input == 0) break; + if (*input != ',') { + return TerminalPathParseResult::InvalidSeparator; + } + input = skipRecentRepeaterSpaces(input + 1); + if (*input == 0) { + return TerminalPathParseResult::InvalidPrefix; + } + } + + result.mode = TerminalPathMode::Explicit; + result.encoded_len = static_cast( + ((hash_size - 1) << 6) | (hop_count & 63)); + result.hash_size = hash_size; + result.hop_count = hop_count; + result.byte_len = offset; + return TerminalPathParseResult::Valid; +} + inline bool parseRecentRepeaterPage(const char* text, int& page) { text = skipRecentRepeaterSpaces(text); if (text == nullptr || *text == 0) { 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 ac08b097..fb777575 100644 --- a/test/test_cli_command_utils/test_cli_command_utils.cpp +++ b/test/test_cli_command_utils/test_cli_command_utils.cpp @@ -122,6 +122,71 @@ TEST(CLICommandUtils, ParsesTerminalLoginAndRemoteCommandArguments) { "logins password", "login", argument)); } +TEST(CLICommandUtils, ParsesTerminalDirectAndExplicitPaths) { + using mesh::cli::TerminalPathMode; + using mesh::cli::TerminalPathParseResult; + uint8_t route[64] = {}; + mesh::cli::TerminalPath path; + + EXPECT_EQ(TerminalPathParseResult::Valid, + mesh::cli::parseTerminalPath( + "direct", route, sizeof(route), 63, path)); + EXPECT_EQ(TerminalPathMode::Direct, path.mode); + EXPECT_EQ(0, path.encoded_len); + EXPECT_EQ(0, path.hop_count); + EXPECT_EQ(0u, path.byte_len); + + EXPECT_EQ(TerminalPathParseResult::Valid, + mesh::cli::parseTerminalPath( + "clear ", route, sizeof(route), 63, path)); + EXPECT_EQ(TerminalPathMode::Clear, path.mode); + + EXPECT_EQ(TerminalPathParseResult::Valid, + mesh::cli::parseTerminalPath( + " A1B2C3, d4e5f6,010203 ", route, sizeof(route), 63, + path)); + EXPECT_EQ(TerminalPathMode::Explicit, path.mode); + EXPECT_EQ(3, path.hash_size); + EXPECT_EQ(3, path.hop_count); + EXPECT_EQ(0x83, path.encoded_len); + EXPECT_EQ(9u, path.byte_len); + const uint8_t expected[] = { + 0xA1, 0xB2, 0xC3, 0xD4, 0xE5, 0xF6, 0x01, 0x02, 0x03, + }; + EXPECT_EQ(0, memcmp(expected, route, sizeof(expected))); +} + +TEST(CLICommandUtils, RejectsMalformedTerminalPaths) { + using mesh::cli::TerminalPathParseResult; + uint8_t route[4] = {}; + mesh::cli::TerminalPath path; + + EXPECT_EQ(TerminalPathParseResult::Missing, + mesh::cli::parseTerminalPath( + nullptr, route, sizeof(route), 63, path)); + EXPECT_EQ(TerminalPathParseResult::Missing, + mesh::cli::parseTerminalPath( + " ", route, sizeof(route), 63, path)); + EXPECT_EQ(TerminalPathParseResult::InvalidPrefix, + mesh::cli::parseTerminalPath( + "GG", route, sizeof(route), 63, path)); + EXPECT_EQ(TerminalPathParseResult::InvalidPrefix, + mesh::cli::parseTerminalPath( + "AA,", route, sizeof(route), 63, path)); + EXPECT_EQ(TerminalPathParseResult::MixedPrefixSize, + mesh::cli::parseTerminalPath( + "AA,BBBB", route, sizeof(route), 63, path)); + EXPECT_EQ(TerminalPathParseResult::InvalidSeparator, + mesh::cli::parseTerminalPath( + "AA BB", route, sizeof(route), 63, path)); + EXPECT_EQ(TerminalPathParseResult::TooManyHops, + mesh::cli::parseTerminalPath( + "AA,BB", route, sizeof(route), 1, path)); + EXPECT_EQ(TerminalPathParseResult::RouteTooLong, + mesh::cli::parseTerminalPath( + "A1B2C3,D4E5F6", route, sizeof(route), 63, path)); +} + TEST(CLICommandUtils, MasksOnlyTerminalLoginPasswordInput) { EXPECT_FALSE(mesh::cli::shouldMaskTerminalInput("login")); EXPECT_FALSE(mesh::cli::shouldMaskTerminalInput("login "));