Add full Companion builds and terminal path routing

This commit is contained in:
mikecarper committed 2026-08-10 16:10:13 -07:00
1 parent 4e91cc9487
commit f40615ab56
8 files changed
+350 -8

No files matched your search

+23
View File
@@ -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-match-spec>: Build all firmwares for build targets containing the string given for <build-match-spec>.
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"
;;
+17 -5
View File
@@ -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 <name>`. 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 <name>`. 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.
+35 -2
View File
@@ -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}
+2 -1
View File
@@ -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)
+89
View File
@@ -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 <direct|clear|hop[,hop...]>\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 <epoch-seconds>\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 <text>\r\n");
Serial.print(" login <admin-password>\r\n");
Serial.print(" cmd <remote-command>\r\n");
+2
View File
@@ -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);
+117
View File
@@ -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 | <hop>[,<hop> ...]
// 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<uint8_t>(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<uint8_t>((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<uint8_t>(
((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) {
@@ -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 "));