diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index d6713a8b..09aef58d 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -81,15 +81,59 @@ USB starts in Binary mode for MeshCore apps and `meshcli`: meshcli -s /dev/ttyACM0 -b 115200 ver ``` -Open the port at 115200 and send the terminal start token as an exact line: +Open the port with the terminal start token sent automatically: ```bash -picocom -b 115200 /dev/ttyACM0 -+++MESHCORE-TERM-START +picocom -b 115200 \ + --imap spchex \ + --initstring '+++MESHCORE-TERM-START' \ + /dev/ttyACM0 ``` -The terminal supports Companion chat commands plus local `ota`, `tempradio`, -and `normalradio` controls. Return to Binary mode with: +The input map prevents any Binary Companion control bytes received during the +mode transition from changing the local terminal's character set or display +state while leaving UTF-8 emoji intact. The banner confirms that terminal mode +is active; do not enter the start token again after it appears. + +The terminal supports Companion chat commands, including `channels`, +`channel `, remote administration with +`login ` and `cmd `, and routed +`trace [recipient-name-or-prefix]`, plus local `ota`, `tempradio`, and +`normalradio` controls. For example: + +```text +channels +channel #rgdata Hello from Eugene 👋 +to Hilltop Repeater +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. + +With no argument, `trace` uses the current `to` recipient. A name-prefix +argument traces that contact directly without changing the current recipient. +The contact must already have a known direct path; results show the SNR at each +hop, or a timeout if the round trip does not return. + +An explicit route can use 1-, 2-, or 4-byte hexadecimal prefixes. Spaces, +commas, and mixed separators are accepted: + +```text +trace path 1 12 34 56 34 12 +trace path 2 1234,ABCD,5678,ABCD,1234 +trace path 4 12345678, ABCDEF01 89ABCDEF, ABCDEF01,12345678 +``` + +The entered route must include both the outward and return prefixes. Exact +three-byte traces are not supported. + +Return to Binary mode with: ```text +++MESHCORE-TERM-STOP @@ -146,12 +190,20 @@ motatool serve --dir ./motas --tcp 192.168.1.50:5001 -v Use the USB terminal briefly to schedule TempRadio, then return to Binary mode and close the terminal: +```bash +picocom -b 115200 \ + --imap spchex \ + --initstring '+++MESHCORE-TERM-START' \ + /dev/ttyACM1 +``` + ```text -+++MESHCORE-TERM-START tempradio 909.950,250,7,5,120 +++MESHCORE-TERM-STOP ``` +After sending the stop token, exit `picocom` with Ctrl-A, Ctrl-X. + Start the serial seeder on that same port: ```bash diff --git a/docs/lora_ota_automation.md b/docs/lora_ota_automation.md index 42cda6e6..27436d15 100644 --- a/docs/lora_ota_automation.md +++ b/docs/lora_ota_automation.md @@ -129,11 +129,20 @@ meshcli -r -s /dev/ttyACM1 -b 115200 "ota status" The command must print an `OTA | ... target:XXXXXXXX` status. -For an nRF52 full Companion, open the source port in a terminal, send -`+++MESHCORE-TERM-START`, and run `ota status`. It must report `OTA seeder`, -`install:disabled`, and target `00000000`; send `+++MESHCORE-TERM-STOP` before -closing the terminal. The automation detects and performs this token-wrapped -preflight itself, so no extra command-line option is needed. +For an nRF52 full Companion, open the source port with terminal mode selected +automatically: + +```bash +picocom -b 115200 \ + --imap spchex \ + --initstring '+++MESHCORE-TERM-START' \ + /dev/ttyACM1 +``` + +Run `ota status`. It must report `OTA seeder`, `install:disabled`, and target +`00000000`; send `+++MESHCORE-TERM-STOP` before closing the terminal. The +automation detects and performs this token-wrapped preflight itself, so no +extra command-line option is needed. For an ESP32 full Companion, test its separate WiFi control console instead: diff --git a/docs/terminal_chat_cli.md b/docs/terminal_chat_cli.md index a5d2347f..2fc642e3 100644 --- a/docs/terminal_chat_cli.md +++ b/docs/terminal_chat_cli.md @@ -5,13 +5,31 @@ Below are the commands you can enter into the Terminal Chat clients: ## Companion USB mode A Companion USB build starts in the normal binary Companion protocol at -115200 baud. To use this terminal from the same firmware image, connect a -serial terminal and send this exact sequence: +115200 baud. Use this command to switch the same USB connection into terminal +mode as soon as `picocom` opens it: + +```sh +picocom --baud 115200 \ + --imap spchex \ + --initstring '+++MESHCORE-TERM-START' \ + /dev/ttyACM0 +``` + +`--initstring` sends this exact terminal-start sequence automatically: ``` +++MESHCORE-TERM-START ``` +Binary Companion frames can contain terminal control bytes. The `spchex` input +map renders those bytes as bracketed hexadecimal during the short transition +instead of allowing them to change the local terminal's character set or +display state. It leaves high-bit bytes unchanged so a UTF-8 terminal displays +emoji and non-ASCII text normally. Do not add `8bithex` unless you explicitly +want UTF-8 bytes displayed as sequences such as `[f0][9f][91][8b]`. Once the +terminal banner appears, the start sequence has already succeeded; do not +enter it again as a terminal command. + Send the following exact sequence to return to the binary protocol: ``` @@ -29,12 +47,6 @@ devices really change the UART timing and receive corrupt data. Binary mode is the framed Companion API used by apps and `meshcli`; close the terminal before opening that port from an app. -For example: - -```sh -picocom --baud 115200 /dev/ttyACM0 -``` - ## Commands ``` @@ -120,11 +132,90 @@ 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) +``` +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. + +Login results arrive asynchronously. A successful modern response displays +the remote ACL permissions byte and server protocol level. A wrong password, +an unreachable target, or a server that does not support remote login normally +produces a timeout because those nodes do not send a rejection packet. + +``` +cmd {remote-command} +``` +Sends CLI data to the current recipient. Wait for the login result before +sending the first command. The remote node applies its own ACL permissions, +and any reply appears asynchronously as `CLI -> from {name}`. + +For example: + +```text +to Hilltop Repeater +login my-admin-password +LOGIN -> Hilltop Repeater accepted (ACL permissions 0x03, server v13) +cmd ver +cmd get radio +``` + +The exact commands and permissions depend on the target firmware. `cmd` does +not run a command on the local Companion; it sends the text over LoRa to the +selected node. + ``` send {text} ``` Sends the text message (as DM) to current recipient. +``` +trace +``` +Traces the saved round-trip route to the current recipient and displays the +SNR at each hop. Select the recipient first with `to {name-prefix}`. + +``` +trace {name-prefix} +``` +Traces a recipient directly without changing the current `to` selection. A +trace requires a known direct path; use normal messaging or path discovery +first if the terminal reports that no valid path is available. Only one +terminal trace can be pending at a time, and a missing response is reported as +a timeout. + +For example: + +```text +to Hilltop Repeater +trace +trace Downtown +``` + +The displayed route uses one- or two-byte node hashes and per-hop SNR values. +Saved three-byte paths are traced with two-byte prefixes because the trace +packet format has no three-byte hash-size mode. + +To trace an explicit route instead of a saved contact path, provide the prefix +size followed by the complete ordered route: + +```text +trace path 1 12 34 56 34 12 +trace path 2 1234,ABCD,5678,ABCD,1234 +trace path 4 12345678, ABCDEF01 89ABCDEF, ABCDEF01,12345678 +``` + +Prefix separators may be spaces, commas, or any mixture of them. Each prefix +must contain exactly 2, 4, or 8 hexadecimal digits for a 1-, 2-, or 4-byte +trace respectively. Three-byte traces are not supported. + +The prefixes are used exactly in the order entered. To receive the trace +result, enter the complete outward route followed by its return route, as in +the mirrored examples above. A route that does not return to this node will +eventually report a timeout. + ``` reset path ``` @@ -133,4 +224,26 @@ Resets the path to current recipient, for new path discovery. ``` public {text} ``` -Sends the text message to the built-in 'public' group channel +Sends the text message to the built-in `Public` group channel. + +``` +channels +``` +Lists the configured channel slots and names without exposing their secrets. + +``` +channel {name-or-slot} {text} +``` +Sends a message to any configured channel by its exact name or numeric slot. +Use the slot shown by `channels` when a channel name contains spaces. + +For example: + +```text +channels +channel #rgdata Hello from Eugene 👋 +channel 2 Another message +``` + +Messages are UTF-8. Emoji use multiple bytes toward the available message +length, which also includes the sender-name prefix added over the air. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 4e6286c3..f34641a4 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -4,6 +4,11 @@ #include #include "helpers/radiolib/RXPowerSaving.h" +#ifdef ENABLE_USB_INTERFACE +#include +#include +#endif + #if defined(COMPANION_RADIO_FULL) #include #endif @@ -888,7 +893,7 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe if (_terminal_mode) { ChannelDetails details; const char* channel_name = getChannel(channel_idx, details) ? details.name : "Unknown"; - Serial.printf("\r\nPUBLIC CHANNEL MSG -> %s (%s)\r\n %s\r\n> ", + Serial.printf("\r\nCHANNEL MSG -> %s (%s)\r\n %s\r\n> ", channel_name, pkt->isRouteDirect() ? "DIRECT" : "FLOOD", text); } #endif @@ -1011,20 +1016,38 @@ uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_tim } void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, uint8_t len) { + if (data == NULL || len < 4) return; + uint32_t tag; memcpy(&tag, data, 4); if (pending_login && memcmp(&pending_login, contact.id.pub_key, 4) == 0) { // check for login response // yes, is response to pending sendLogin() +#ifdef ENABLE_USB_INTERFACE + const bool terminal_login_response = _terminal_login_pending + && memcmp(_terminal_login_key, contact.id.pub_key, + sizeof(_terminal_login_key)) == 0; +#endif pending_login = 0; int i = 0; - if (memcmp(&data[4], "OK", 2) == 0) { // legacy Repeater login OK response +#ifdef ENABLE_USB_INTERFACE + bool login_success = false; + bool modern_login = false; +#endif + if (len >= 6 && memcmp(&data[4], "OK", 2) == 0) { // legacy Repeater login OK response +#ifdef ENABLE_USB_INTERFACE + login_success = true; +#endif out_frame[i++] = PUSH_CODE_LOGIN_SUCCESS; out_frame[i++] = 0; // legacy: is_admin = false memcpy(&out_frame[i], contact.id.pub_key, 6); i += 6; // pub_key_prefix - } else if (data[4] == RESP_SERVER_LOGIN_OK) { // new login response + } else if (len >= 13 && data[4] == RESP_SERVER_LOGIN_OK) { // new login response +#ifdef ENABLE_USB_INTERFACE + login_success = true; + modern_login = true; +#endif uint16_t keep_alive_secs = ((uint16_t)data[5]) * 16; if (keep_alive_secs > 0) { startConnection(contact, keep_alive_secs); @@ -1044,6 +1067,24 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, i += 6; // pub_key_prefix } _serial->writeFrame(out_frame, i); +#ifdef ENABLE_USB_INTERFACE + if (terminal_login_response) { + if (_terminal_mode) { + if (login_success && modern_login) { + Serial.printf("\r\nLOGIN -> %s accepted (ACL permissions 0x%02X, server v%u)\r\n> ", + _terminal_login_target, (unsigned)data[7], + (unsigned)data[12]); + } else if (login_success) { + Serial.printf("\r\nLOGIN -> %s accepted (legacy server)\r\n> ", + _terminal_login_target); + } else { + Serial.printf("\r\nLOGIN -> %s rejected\r\n> ", + _terminal_login_target); + } + } + clearTerminalLogin(); + } +#endif } else if (len > 4 && // check for status response pending_status && memcmp(&pending_status, contact.id.pub_key, 4) == 0 // legacy matching scheme @@ -1178,6 +1219,35 @@ void MyMesh::onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, i += path_len >> path_sz; out_frame[i++] = (int8_t)(packet->getSNR() * 4); // extra/final SNR (to this node) +#ifdef ENABLE_USB_INTERFACE + if (_terminal_mode && _terminal_trace_pending + && tag == _terminal_trace_tag && auth_code == _terminal_trace_auth) { + const uint8_t hash_size = _terminal_trace_hash_size; + const uint8_t hop_count = hash_size == 0 ? 0 : path_len / hash_size; + const uint8_t response_hash_size = 1 << (flags & 0x03); + const unsigned long elapsed = _ms->getMillis() - _terminal_trace_sent_at; + Serial.printf("\r\nTRACE -> %s (%lu ms)\r\n", + _terminal_trace_target, elapsed); + if (hash_size == 0 || response_hash_size != hash_size + || path_len % hash_size != 0 + || hop_count >= MAX_PATH_SIZE) { + Serial.print(" ERROR: malformed trace response\r\n> "); + } else { + Serial.print(" "); + for (uint8_t hop = 0; hop < hop_count; hop++) { + Serial.print(((float)(int8_t)path_snrs[hop]) / 4.0f, 2); + Serial.print(" dB -> ["); + mesh::Utils::printHex(Serial, &path_hashes[hop * hash_size], + hash_size); + Serial.print("] -> "); + } + Serial.print(packet->getSNR(), 2); + Serial.print(" dB\r\n> "); + } + clearTerminalTrace(); + } +#endif + if (_serial->isConnected()) { _serial->writeFrame(out_frame, i); } else { @@ -1208,6 +1278,11 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _terminal_mode = false; _terminal_recipient_set = false; memset(_terminal_recipient_key, 0, sizeof(_terminal_recipient_key)); + _terminal_login_pending = false; + memset(_terminal_login_key, 0, sizeof(_terminal_login_key)); + _terminal_login_expires_at = 0; + _terminal_login_target[0] = 0; + clearTerminalTrace(); #endif saved_radio_apply_pending = false; radio_apply_retry_at = 0; @@ -3358,6 +3433,8 @@ void MyMesh::enterTerminalMode() { _terminal_mode = true; _terminal_recipient_set = false; memset(_terminal_recipient_key, 0, sizeof(_terminal_recipient_key)); + clearTerminalLogin(); + clearTerminalTrace(); Serial.print("\r\n===== MeshCore Chat Terminal =====\r\n\r\n"); Serial.printf("WELCOME %s\r\n", _prefs.node_name); @@ -3371,6 +3448,8 @@ void MyMesh::exitTerminalMode() { _terminal_mode = false; _terminal_recipient_set = false; memset(_terminal_recipient_key, 0, sizeof(_terminal_recipient_key)); + clearTerminalLogin(); + clearTerminalTrace(); } ContactInfo* MyMesh::getTerminalRecipient() { @@ -3446,8 +3525,278 @@ void MyMesh::importTerminalCard(char* command) { Serial.print(" OK - contact import queued\r\n"); } +void MyMesh::listTerminalChannels() { + bool found = false; + Serial.print("Channels:\r\n"); + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { + ChannelDetails channel; + if (getChannel(i, channel) && channel.name[0] != 0) { + Serial.printf(" %d: %s\r\n", i, channel.name); + found = true; + } + } + if (!found) Serial.print(" (none configured)\r\n"); +} + +void MyMesh::sendTerminalChannelMessage(ChannelDetails& channel, + const char* text) { + const size_t prefix_len = strlen(_prefs.node_name) + 2; // "name: " + const size_t max_text_len = prefix_len < MAX_TEXT_LEN + ? MAX_TEXT_LEN - prefix_len : 0; + const size_t text_len = text == NULL ? 0 : strlen(text); + if (text_len == 0) { + Serial.print(" ERROR: message is empty\r\n"); + } else if (text_len > max_text_len) { + Serial.printf(" ERROR: message must be 1-%u UTF-8 bytes for this node name\r\n", + (unsigned)max_text_len); + } else if (sendGroupMessage(getRTCClock()->getCurrentTimeUnique(), + channel.channel, _prefs.node_name, text, + text_len)) { + Serial.printf(" Sent to %s.\r\n", channel.name); + } else { + Serial.print(" ERROR: unable to send\r\n"); + } +} + +void MyMesh::clearTerminalLogin() { + if (_terminal_login_pending && pending_login != 0 + && memcmp(&pending_login, _terminal_login_key, + sizeof(_terminal_login_key)) == 0) { + pending_login = 0; + } + _terminal_login_pending = false; + memset(_terminal_login_key, 0, sizeof(_terminal_login_key)); + _terminal_login_expires_at = 0; + _terminal_login_target[0] = 0; +} + +void MyMesh::serviceTerminalLogin() { + if (!_terminal_login_pending) return; + const unsigned long now = _ms->getMillis(); + if (_terminal_login_expires_at != now + && !millisHasNowPassed(_terminal_login_expires_at)) { + return; + } + + if (_terminal_mode) { + Serial.printf("\r\n ERROR: login to %s timed out (wrong password or no response).\r\n> ", + _terminal_login_target); + } + clearTerminalLogin(); +} + +void MyMesh::sendTerminalLogin(ContactInfo& recipient, + const char* password) { + serviceTerminalLogin(); + if (_terminal_login_pending) { + Serial.printf(" ERROR: login to %s is still pending\r\n", + _terminal_login_target); + return; + } + + const size_t password_len = password == NULL ? 0 : strlen(password); + if (password_len == 0 || password_len > 15) { + Serial.print(" ERROR: password must be 1-15 UTF-8 bytes\r\n"); + return; + } + + uint32_t est_timeout = 0; + const int result = sendLogin(recipient, password, est_timeout); + if (result == MSG_SEND_FAILED) { + Serial.print(" ERROR: unable to send login\r\n"); + return; + } + + clearPendingReqs(); + memcpy(&pending_login, recipient.id.pub_key, sizeof(pending_login)); + _terminal_login_pending = true; + memcpy(_terminal_login_key, recipient.id.pub_key, + sizeof(_terminal_login_key)); + const uint32_t timeout = est_timeout + est_timeout / 5; + _terminal_login_expires_at = futureMillis(timeout); + StrHelper::strzcpy(_terminal_login_target, recipient.name, + sizeof(_terminal_login_target)); + Serial.printf(" Login sent to %s (%s, timeout %lu ms)\r\n", + recipient.name, + result == MSG_SEND_SENT_FLOOD ? "FLOOD" : "DIRECT", + (unsigned long)timeout); +} + +void MyMesh::sendTerminalCommand(ContactInfo& recipient, + const char* command) { + serviceTerminalLogin(); + if (_terminal_login_pending) { + Serial.printf(" ERROR: login to %s is still pending\r\n", + _terminal_login_target); + return; + } + + const size_t command_len = command == NULL ? 0 : strlen(command); + if (command_len == 0 || command_len > MAX_TEXT_LEN) { + Serial.printf(" ERROR: remote command must be 1-%u UTF-8 bytes\r\n", + (unsigned)MAX_TEXT_LEN); + return; + } + + const uint32_t logical_request_id = + getRTCClock()->getCurrentTimeUnique(); + const uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); + uint32_t est_timeout = 0; + const int result = sendCommandData(recipient, timestamp, 0, command, + est_timeout, logical_request_id); + if (result == MSG_SEND_FAILED) { + Serial.print(" ERROR: unable to send remote command\r\n"); + return; + } + + Serial.printf(" Remote command sent to %s (%s, timeout %lu ms)\r\n", + recipient.name, + result == MSG_SEND_SENT_FLOOD ? "FLOOD" : "DIRECT", + (unsigned long)est_timeout); +} + +void MyMesh::clearTerminalTrace() { + _terminal_trace_pending = false; + _terminal_trace_hash_size = 0; + _terminal_trace_tag = 0; + _terminal_trace_auth = 0; + _terminal_trace_sent_at = 0; + _terminal_trace_expires_at = 0; + _terminal_trace_target[0] = 0; +} + +void MyMesh::serviceTerminalTrace() { + if (!_terminal_trace_pending) return; + const unsigned long now = _ms->getMillis(); + if (_terminal_trace_expires_at != now + && !millisHasNowPassed(_terminal_trace_expires_at)) { + return; + } + + if (_terminal_mode) { + Serial.printf("\r\n ERROR: trace to %s timed out.\r\n> ", + _terminal_trace_target); + } + clearTerminalTrace(); +} + +void MyMesh::sendTerminalTraceRoute(const uint8_t* route, uint8_t hash_size, + uint8_t hop_count, const char* target) { + serviceTerminalTrace(); + if (_terminal_trace_pending) { + Serial.printf(" ERROR: trace to %s is still pending\r\n", + _terminal_trace_target); + return; + } + if (route == NULL || hop_count == 0 || hop_count >= MAX_PATH_SIZE) { + Serial.print(" ERROR: trace path must contain 1-63 prefixes\r\n"); + return; + } + + const size_t route_byte_len = static_cast(hash_size) * hop_count; + if (route_byte_len > MAX_PACKET_PAYLOAD - 9) { + Serial.print(" ERROR: trace path is too long\r\n"); + return; + } + + const uint8_t flags = mesh::traceFlagsForHashSize(hash_size); + if (flags == 0xFF) { + Serial.print(" ERROR: trace hash size must be 1, 2, or 4 bytes\r\n"); + return; + } + + uint32_t tag = 0; + uint32_t auth = 0; + getRNG()->random((uint8_t*)&tag, sizeof(tag)); + getRNG()->random((uint8_t*)&auth, sizeof(auth)); + mesh::Packet* packet = createTrace(tag, auth, flags); + if (packet == NULL) { + Serial.print(" ERROR: unable to allocate trace packet\r\n"); + return; + } + + const uint32_t airtime = _radio->getEstAirtimeFor(9 + route_byte_len + 2); + const uint32_t timeout = calcDirectTimeoutMillisFor(airtime, hop_count); + if (!sendDirect(packet, route, static_cast(route_byte_len))) { + Serial.print(" ERROR: unable to send trace\r\n"); + return; + } + + _terminal_trace_pending = true; + _terminal_trace_hash_size = hash_size; + _terminal_trace_tag = tag; + _terminal_trace_auth = auth; + _terminal_trace_sent_at = _ms->getMillis(); + _terminal_trace_expires_at = futureMillis(timeout + timeout / 5); + StrHelper::strzcpy(_terminal_trace_target, target, + sizeof(_terminal_trace_target)); + Serial.printf(" Trace sent to %s (%u route hops, timeout %lu ms)\r\n", + target, (unsigned)hop_count, + (unsigned long)(timeout + timeout / 5)); +} + +void MyMesh::sendTerminalTrace(ContactInfo& recipient) { + if (recipient.out_path_len == OUT_PATH_UNKNOWN + || !mesh::Packet::isValidPathLen(recipient.out_path_len)) { + Serial.print(" ERROR: recipient has no valid direct path\r\n"); + return; + } + + const bool include_endpoint = recipient.type == ADV_TYPE_REPEATER + || recipient.type == ADV_TYPE_ROOM; + mesh::RoundTripTracePath route; + if (!mesh::buildRoundTripTracePath( + recipient.out_path, recipient.out_path_len, recipient.id.pub_key, + include_endpoint, _terminal_tmp_buf, MAX_PACKET_PAYLOAD - 9, + route)) { + Serial.print(" ERROR: recipient has no traceable round-trip path\r\n"); + return; + } + if (route.hop_count >= MAX_PATH_SIZE) { + Serial.print(" ERROR: round-trip trace path is too long\r\n"); + return; + } + + sendTerminalTraceRoute(_terminal_tmp_buf, route.hash_size, route.hop_count, + recipient.name); +} + +void MyMesh::sendTerminalRawTrace(const char* arguments) { + mesh::RawTracePath route; + const mesh::RawTracePathParseResult parsed = mesh::parseRawTracePath( + arguments, _terminal_tmp_buf, MAX_PACKET_PAYLOAD - 9, + MAX_PATH_SIZE - 1, route); + switch (parsed) { + case mesh::RawTracePathParseResult::Valid: + break; + case mesh::RawTracePathParseResult::MissingHashSize: + case mesh::RawTracePathParseResult::MissingPrefixes: + Serial.print(" ERROR: use trace path <1|2|4> \r\n"); + return; + case mesh::RawTracePathParseResult::InvalidHashSize: + Serial.print(" ERROR: trace hash size must be 1, 2, or 4 bytes\r\n"); + return; + case mesh::RawTracePathParseResult::InvalidPrefix: + Serial.printf(" ERROR: every prefix must be exactly %u hex digits\r\n", + (unsigned)route.hash_size * 2); + return; + case mesh::RawTracePathParseResult::TooManyHops: + Serial.print(" ERROR: trace path must contain at most 63 prefixes\r\n"); + return; + case mesh::RawTracePathParseResult::RouteTooLong: + Serial.print(" ERROR: trace path is too long\r\n"); + return; + } + + char target[32]; + snprintf(target, sizeof(target), "raw %u-byte path", + (unsigned)route.hash_size); + sendTerminalTraceRoute(_terminal_tmp_buf, route.hash_size, route.hop_count, + target); +} + void MyMesh::handleTerminalCommand(char* command) { - while (*command == ' ') command++; + while (*command == ' ' || *command == '\t') command++; if (*command == 0) return; #if defined(COMPANION_RADIO_FULL) @@ -3458,7 +3807,50 @@ void MyMesh::handleTerminalCommand(char* command) { } #endif - if (strncmp(command, "send ", 5) == 0) { + mesh::cli::TerminalChannelMessage channel_message; + const mesh::cli::TerminalChannelCommandMatch channel_match = + mesh::cli::parseTerminalChannelMessage(command, channel_message); + const char* login_password = NULL; + const mesh::cli::TerminalArgumentCommandMatch login_match = + mesh::cli::parseTerminalArgumentCommand(command, "login", + login_password); + const char* remote_command = NULL; + const mesh::cli::TerminalArgumentCommandMatch command_match = + mesh::cli::parseTerminalArgumentCommand(command, "cmd", + remote_command); + + if (strcmp(command, "channels") == 0) { + listTerminalChannels(); + } else if (channel_match != mesh::cli::TerminalChannelCommandMatch::NoMatch) { + if (channel_match != mesh::cli::TerminalChannelCommandMatch::Valid) { + Serial.print(" ERROR: use channel \r\n"); + return; + } + + ChannelDetails channel; + bool found = false; + size_t requested_index = 0; + if (mesh::cli::parseTerminalChannelIndex( + channel_message, MAX_GROUP_CHANNELS, requested_index)) { + found = getChannel((int)requested_index, channel) + && channel.name[0] != 0; + } else { + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { + if (getChannel(i, channel) && channel.name[0] != 0 + && mesh::cli::terminalChannelNameMatches(channel_message, + channel.name)) { + found = true; + break; + } + } + } + + if (!found) { + Serial.print(" ERROR: channel not found (use 'channels')\r\n"); + } else { + sendTerminalChannelMessage(channel, channel_message.text); + } + } else if (strncmp(command, "send ", 5) == 0) { ContactInfo* recipient = getTerminalRecipient(); const char* text = command + 5; if (recipient == NULL) { @@ -3491,15 +3883,10 @@ void MyMesh::handleTerminalCommand(char* command) { } else if (strncmp(command, "public ", 7) == 0) { ChannelDetails channel; const char* text = command + 7; - if (*text == 0) { - Serial.print(" ERROR: message is empty\r\n"); - } else if (!getChannel(0, channel)) { + if (!getChannel(0, channel) || channel.name[0] == 0) { Serial.print(" ERROR: Public channel is unavailable\r\n"); - } else if (sendGroupMessage(getRTCClock()->getCurrentTimeUnique(), channel.channel, - _prefs.node_name, text, strlen(text))) { - Serial.print(" Sent.\r\n"); } else { - Serial.print(" ERROR: unable to send\r\n"); + sendTerminalChannelMessage(channel, text); } } else if (strcmp(command, "list") == 0 || strncmp(command, "list ", 5) == 0) { int count = command[4] == ' ' ? atoi(command + 5) : 0; @@ -3537,6 +3924,53 @@ void MyMesh::handleTerminalCommand(char* command) { } else { Serial.print(" No recipient selected\r\n"); } + } else if (login_match + != mesh::cli::TerminalArgumentCommandMatch::NoMatch) { + ContactInfo* recipient = getTerminalRecipient(); + if (login_match + != mesh::cli::TerminalArgumentCommandMatch::Valid) { + Serial.print(" ERROR: use login \r\n"); + } else if (recipient == NULL) { + Serial.print(" ERROR: no recipient selected (use 'to' first)\r\n"); + } else { + sendTerminalLogin(*recipient, login_password); + } + } else if (command_match + != mesh::cli::TerminalArgumentCommandMatch::NoMatch) { + ContactInfo* recipient = getTerminalRecipient(); + if (command_match + != mesh::cli::TerminalArgumentCommandMatch::Valid) { + Serial.print(" ERROR: use cmd \r\n"); + } else if (recipient == NULL) { + Serial.print(" ERROR: no recipient selected (use 'to' first)\r\n"); + } else { + sendTerminalCommand(*recipient, remote_command); + } + } else if (strcmp(command, "trace path") == 0 + || strncmp(command, "trace path ", 11) == 0 + || strncmp(command, "trace path\t", 11) == 0) { + sendTerminalRawTrace(command + 10); + } else if (strcmp(command, "trace") == 0 + || strncmp(command, "trace ", 6) == 0) { + ContactInfo* recipient = NULL; + if (command[5] == ' ') { + const char* prefix = command + 6; + while (*prefix == ' ') prefix++; + if (*prefix != 0 && strlen(prefix) < sizeof(ContactInfo::name)) { + recipient = searchContactsByPrefix(prefix); + } + if (recipient == NULL || recipient->type == ADV_TYPE_NONE) { + Serial.print(" ERROR: recipient prefix not found\r\n"); + return; + } + } else { + recipient = getTerminalRecipient(); + if (recipient == NULL) { + Serial.print(" ERROR: no recipient selected (use 'to' first)\r\n"); + return; + } + } + sendTerminalTrace(*recipient); } else if (strcmp(command, "advert") == 0) { Serial.print(advert() ? " advert sent (zero hop)\r\n" : " ERROR: unable to send advert\r\n"); @@ -3607,9 +4041,15 @@ void MyMesh::handleTerminalCommand(char* command) { Serial.print(" list [n]\r\n"); Serial.print(" to [recipient name or prefix]\r\n"); Serial.print(" send \r\n"); + Serial.print(" login \r\n"); + Serial.print(" cmd \r\n"); + Serial.print(" trace [recipient name or prefix]\r\n"); + Serial.print(" trace path <1|2|4> \r\n"); Serial.print(" advert\r\n"); Serial.print(" reset path\r\n"); Serial.print(" public \r\n"); + Serial.print(" channels\r\n"); + Serial.print(" channel \r\n"); #if defined(COMPANION_RADIO_FULL) Serial.print(" tempradio [freq,bw,sf,cr,minutes]\r\n"); Serial.print(" normalradio\r\n"); @@ -3839,6 +4279,10 @@ void MyMesh::loop() { serviceTempRadio(); #endif BaseChatMesh::loop(); +#ifdef ENABLE_USB_INTERFACE + serviceTerminalLogin(); + serviceTerminalTrace(); +#endif if (!command_radio_apply_pending && saved_radio_apply_pending && !hasOutbound() #if defined(COMPANION_RADIO_FULL) && !_temp_radio_applied && _temp_radio_set_at == 0 diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 1adb7853..bed4360a 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -275,6 +275,18 @@ private: #ifdef ENABLE_USB_INTERFACE ContactInfo* getTerminalRecipient(); void importTerminalCard(char* command); + void listTerminalChannels(); + void sendTerminalChannelMessage(ChannelDetails& channel, const char* text); + void clearTerminalLogin(); + void serviceTerminalLogin(); + void sendTerminalLogin(ContactInfo& recipient, const char* password); + void sendTerminalCommand(ContactInfo& recipient, const char* command); + void clearTerminalTrace(); + void serviceTerminalTrace(); + void sendTerminalTraceRoute(const uint8_t* route, uint8_t hash_size, + uint8_t hop_count, const char* target); + void sendTerminalTrace(ContactInfo& recipient); + void sendTerminalRawTrace(const char* arguments); #endif bool isValidClientRepeatFreq(uint32_t f) const; bool hasLocationTelemetryRecipient(); @@ -327,6 +339,17 @@ private: bool _terminal_recipient_set; uint8_t _terminal_recipient_key[PUB_KEY_SIZE]; uint8_t _terminal_tmp_buf[MAX_TRANS_UNIT]; + bool _terminal_login_pending; + uint8_t _terminal_login_key[4]; + unsigned long _terminal_login_expires_at; + char _terminal_login_target[32]; + bool _terminal_trace_pending; + uint8_t _terminal_trace_hash_size; + uint32_t _terminal_trace_tag; + uint32_t _terminal_trace_auth; + unsigned long _terminal_trace_sent_at; + unsigned long _terminal_trace_expires_at; + char _terminal_trace_target[32]; #endif bool saved_radio_apply_pending; unsigned long radio_apply_retry_at; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index b63378b7..a872fd1c 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -58,6 +58,7 @@ MultiSerialInterface interface_manager; // include usb interface #if defined(ENABLE_USB_INTERFACE) #include + #include static const char USB_TERMINAL_START_TOKEN[] = "+++MESHCORE-TERM-START"; static const char USB_TERMINAL_STOP_TOKEN[] = "+++MESHCORE-TERM-STOP"; #if defined(NRF52_PLATFORM) && defined(COMPANION_RADIO_FULL) && defined(OTA_FOLDER_SERIAL) @@ -133,6 +134,11 @@ static size_t usb_mota_line_len = 0; static bool usb_mota_disconnect_armed = false; #endif +static void clearUsbTerminalLine() { + memset(usb_terminal_line, 0, sizeof(usb_terminal_line)); + usb_terminal_line_len = 0; +} + static bool isUsbTerminalDataConnected() { #if defined(RP2040_PLATFORM) return (bool)Serial; @@ -143,8 +149,7 @@ static bool isUsbTerminalDataConnected() { static void enterUsbTerminalMode() { usb_serial_interface.setPassthroughMode(true); - usb_terminal_line_len = 0; - usb_terminal_line[0] = 0; + clearUsbTerminalLine(); usb_terminal_discard_line = false; usb_terminal_disconnect_armed = isUsbTerminalDataConnected(); the_mesh.enterTerminalMode(); @@ -156,8 +161,7 @@ static void leaveUsbTerminalMode(bool acknowledge) { } the_mesh.exitTerminalMode(); usb_serial_interface.setPassthroughMode(false); - usb_terminal_line_len = 0; - usb_terminal_line[0] = 0; + clearUsbTerminalLine(); usb_terminal_discard_line = false; usb_terminal_disconnect_armed = false; } @@ -291,15 +295,13 @@ static void serviceUsbTerminal() { if (usb_terminal_line_len == 0) continue; Serial.print("\r\n"); the_mesh.handleTerminalCommand(usb_terminal_line); - usb_terminal_line_len = 0; - usb_terminal_line[0] = 0; + clearUsbTerminalLine(); Serial.print("> "); return; // service at most one command per mesh loop } if (usb_terminal_line_len >= sizeof(usb_terminal_line) - 1) { - usb_terminal_line_len = 0; - usb_terminal_line[0] = 0; + clearUsbTerminalLine(); usb_terminal_discard_line = true; Serial.print("\r\n ERROR: command too long\r\n"); continue; @@ -307,7 +309,8 @@ static void serviceUsbTerminal() { usb_terminal_line[usb_terminal_line_len++] = c; usb_terminal_line[usb_terminal_line_len] = 0; - Serial.print(c); + Serial.print(mesh::cli::shouldMaskTerminalInput(usb_terminal_line) ? '*' + : c); if (strcmp(usb_terminal_line, USB_TERMINAL_STOP_TOKEN) == 0) { leaveUsbTerminalMode(true); diff --git a/src/helpers/CLICommandUtils.h b/src/helpers/CLICommandUtils.h index 04b5140d..fddab1b3 100644 --- a/src/helpers/CLICommandUtils.h +++ b/src/helpers/CLICommandUtils.h @@ -29,17 +29,128 @@ enum class RecentRepeaterGetMatch : uint8_t { Invalid, }; +enum class TerminalChannelCommandMatch : uint8_t { + NoMatch = 0, + Valid, + MissingSelector, + MissingMessage, +}; + +enum class TerminalArgumentCommandMatch : uint8_t { + NoMatch = 0, + Valid, + MissingArgument, +}; + struct RecentRepeaterGetQuery { int page; uint8_t search_prefix[3]; uint8_t search_prefix_len; }; +struct TerminalChannelMessage { + const char* selector; + size_t selector_len; + const char* text; +}; + inline const char* skipRecentRepeaterSpaces(const char* text) { while (text != nullptr && (*text == ' ' || *text == '\t')) text++; return text; } +inline TerminalArgumentCommandMatch parseTerminalArgumentCommand( + const char* command, const char* verb, const char*& argument) { + argument = nullptr; + if (command == nullptr || verb == nullptr || *verb == 0) { + return TerminalArgumentCommandMatch::NoMatch; + } + + const size_t verb_len = strlen(verb); + for (size_t i = 0; i < verb_len; i++) { + char actual = command[i]; + char expected = verb[i]; + if (actual >= 'A' && actual <= 'Z') actual += 'a' - 'A'; + if (expected >= 'A' && expected <= 'Z') expected += 'a' - 'A'; + if (actual != expected) return TerminalArgumentCommandMatch::NoMatch; + } + if (command[verb_len] != 0 && command[verb_len] != ' ' + && command[verb_len] != '\t') { + return TerminalArgumentCommandMatch::NoMatch; + } + + const char* cursor = skipRecentRepeaterSpaces(command + verb_len); + if (*cursor == 0) return TerminalArgumentCommandMatch::MissingArgument; + argument = cursor; + return TerminalArgumentCommandMatch::Valid; +} + +// Return true once terminal input has reached a login password. The caller +// can still retain the real bytes for command handling while echoing '*'. +inline bool shouldMaskTerminalInput(const char* line) { + line = skipRecentRepeaterSpaces(line); + const char* password = nullptr; + return parseTerminalArgumentCommand(line, "login", password) + == TerminalArgumentCommandMatch::Valid; +} + +inline TerminalChannelCommandMatch parseTerminalChannelMessage( + const char* command, TerminalChannelMessage& message) { + message.selector = nullptr; + message.selector_len = 0; + message.text = nullptr; + if (command == nullptr || strncmp(command, "channel", 7) != 0) { + return TerminalChannelCommandMatch::NoMatch; + } + + const char* cursor = command + 7; + if (*cursor != 0 && *cursor != ' ' && *cursor != '\t') { + return TerminalChannelCommandMatch::NoMatch; + } + cursor = skipRecentRepeaterSpaces(cursor); + if (*cursor == 0) return TerminalChannelCommandMatch::MissingSelector; + + message.selector = cursor; + while (*cursor != 0 && *cursor != ' ' && *cursor != '\t') cursor++; + message.selector_len = static_cast(cursor - message.selector); + cursor = skipRecentRepeaterSpaces(cursor); + if (*cursor == 0) return TerminalChannelCommandMatch::MissingMessage; + + message.text = cursor; + return TerminalChannelCommandMatch::Valid; +} + +inline bool parseTerminalChannelIndex(const TerminalChannelMessage& message, + size_t max_channels, + size_t& channel_index) { + if (message.selector == nullptr || message.selector_len == 0 + || max_channels == 0) { + return false; + } + + size_t value = 0; + for (size_t i = 0; i < message.selector_len; i++) { + const char c = message.selector[i]; + if (c < '0' || c > '9') return false; + const size_t digit = static_cast(c - '0'); + if (digit >= max_channels + || value > (max_channels - 1 - digit) / 10) { + return false; + } + value = value * 10 + digit; + } + if (value >= max_channels) return false; + channel_index = value; + return true; +} + +inline bool terminalChannelNameMatches(const TerminalChannelMessage& message, + const char* channel_name) { + return message.selector != nullptr && channel_name != nullptr + && strlen(channel_name) == message.selector_len + && memcmp(channel_name, message.selector, message.selector_len) == 0; +} + inline int recentRepeaterHexNibble(char c) { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return c - 'a' + 10; diff --git a/src/helpers/TracePathHelpers.h b/src/helpers/TracePathHelpers.h new file mode 100644 index 00000000..68ba73cf --- /dev/null +++ b/src/helpers/TracePathHelpers.h @@ -0,0 +1,174 @@ +#pragma once + +#include +#include +#include + +namespace mesh { + +struct RoundTripTracePath { + uint8_t hash_size; + uint8_t hop_count; + size_t byte_len; +}; + +enum class RawTracePathParseResult : uint8_t { + Valid, + MissingHashSize, + InvalidHashSize, + MissingPrefixes, + InvalidPrefix, + TooManyHops, + RouteTooLong, +}; + +struct RawTracePath { + uint8_t hash_size; + uint8_t hop_count; + size_t byte_len; +}; + +inline bool isRawTracePathSeparator(char c) { + return c == ' ' || c == '\t' || c == ','; +} + +inline int rawTraceHexNibble(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; +} + +// Parse: [, ...] +// Prefix separators may be commas, spaces, tabs, or any mixture of them. +// TRACE uses power-of-two hash sizes, so only 1, 2, and 4 are accepted. +inline RawTracePathParseResult parseRawTracePath(const char* input, + uint8_t* output, + size_t output_capacity, + uint8_t max_hops, + RawTracePath& result) { + result.hash_size = 0; + result.hop_count = 0; + result.byte_len = 0; + + if (input == nullptr) return RawTracePathParseResult::MissingHashSize; + while (*input == ' ' || *input == '\t') input++; + if (*input == 0) return RawTracePathParseResult::MissingHashSize; + + const char width_char = *input++; + if (width_char != '1' && width_char != '2' && width_char != '4') { + return RawTracePathParseResult::InvalidHashSize; + } + if (*input != 0 && !isRawTracePathSeparator(*input)) { + return RawTracePathParseResult::InvalidHashSize; + } + const uint8_t hash_size = static_cast(width_char - '0'); + result.hash_size = hash_size; + + while (isRawTracePathSeparator(*input)) input++; + if (*input == 0) return RawTracePathParseResult::MissingPrefixes; + if (output == nullptr || output_capacity < hash_size) { + return RawTracePathParseResult::RouteTooLong; + } + + const size_t expected_chars = static_cast(hash_size) * 2; + size_t offset = 0; + uint8_t hop_count = 0; + while (*input != 0) { + const char* token = input; + size_t token_len = 0; + while (input[token_len] != 0 + && !isRawTracePathSeparator(input[token_len])) { + token_len++; + } + if (token_len != expected_chars) { + return RawTracePathParseResult::InvalidPrefix; + } + if (hop_count >= max_hops) { + return RawTracePathParseResult::TooManyHops; + } + if (offset + hash_size > output_capacity) { + return RawTracePathParseResult::RouteTooLong; + } + + for (uint8_t i = 0; i < hash_size; i++) { + const int high = rawTraceHexNibble(token[i * 2]); + const int low = rawTraceHexNibble(token[i * 2 + 1]); + if (high < 0 || low < 0) { + return RawTracePathParseResult::InvalidPrefix; + } + output[offset++] = static_cast((high << 4) | low); + } + hop_count++; + input += token_len; + while (isRawTracePathSeparator(*input)) input++; + } + + result.hash_size = hash_size; + result.hop_count = hop_count; + result.byte_len = offset; + return RawTracePathParseResult::Valid; +} + +// Build the route used by Companion clients for a trace that returns to its +// origin: saved path, optional repeater/room endpoint, then the saved path in +// reverse. Stored three-byte paths are traced with their first two bytes per +// hop because the TRACE flag format has no unambiguous three-byte mode. +inline bool buildRoundTripTracePath(const uint8_t* saved_path, + uint8_t encoded_path_len, + const uint8_t* endpoint_hash, + bool include_endpoint, + uint8_t* output, + size_t output_capacity, + RoundTripTracePath& result) { + result.hash_size = 0; + result.hop_count = 0; + result.byte_len = 0; + + const size_t saved_hash_size = (encoded_path_len >> 6) + 1; + const size_t saved_hop_count = encoded_path_len & 63; + if (saved_hash_size == 0 || saved_hash_size > 3 + || (saved_hop_count > 0 && saved_path == nullptr) + || (include_endpoint && endpoint_hash == nullptr)) { + return false; + } + + const size_t trace_hash_size = saved_hash_size == 3 ? 2 : saved_hash_size; + const size_t route_hop_count = saved_hop_count * 2 + + (include_endpoint ? 1 : 0); + if (route_hop_count == 0 || route_hop_count > 255 + || trace_hash_size > output_capacity + || route_hop_count > output_capacity / trace_hash_size + || output == nullptr) { + return false; + } + + size_t offset = 0; + for (size_t i = 0; i < saved_hop_count; i++) { + memcpy(&output[offset], &saved_path[i * saved_hash_size], trace_hash_size); + offset += trace_hash_size; + } + if (include_endpoint) { + memcpy(&output[offset], endpoint_hash, trace_hash_size); + offset += trace_hash_size; + } + for (size_t i = saved_hop_count; i > 0; i--) { + memcpy(&output[offset], &saved_path[(i - 1) * saved_hash_size], + trace_hash_size); + offset += trace_hash_size; + } + + result.hash_size = static_cast(trace_hash_size); + result.hop_count = static_cast(route_hop_count); + result.byte_len = offset; + return true; +} + +inline uint8_t traceFlagsForHashSize(uint8_t hash_size) { + return hash_size == 1 ? 0 + : hash_size == 2 ? 1 + : hash_size == 4 ? 2 + : 0xFF; +} + +} // namespace mesh 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 37c8773f..ac08b097 100644 --- a/test/test_cli_command_utils/test_cli_command_utils.cpp +++ b/test/test_cli_command_utils/test_cli_command_utils.cpp @@ -40,6 +40,98 @@ TEST(CLICommandUtils, HandlesLeadingWhitespaceAndSingleWordCommands) { EXPECT_STREQ("powersaving", single_command); } +TEST(CLICommandUtils, ParsesTerminalChannelMessagesWithUtf8Text) { + using mesh::cli::TerminalChannelCommandMatch; + mesh::cli::TerminalChannelMessage message; + + EXPECT_EQ(TerminalChannelCommandMatch::Valid, + mesh::cli::parseTerminalChannelMessage( + "channel #rgdata Hello from Eugene 👋", message)); + EXPECT_EQ(7u, message.selector_len); + EXPECT_EQ(0, memcmp("#rgdata", message.selector, message.selector_len)); + EXPECT_STREQ("Hello from Eugene 👋", message.text); + EXPECT_TRUE(mesh::cli::terminalChannelNameMatches(message, "#rgdata")); + EXPECT_FALSE(mesh::cli::terminalChannelNameMatches(message, "#RGDATA")); +} + +TEST(CLICommandUtils, ParsesTerminalChannelSlotsStrictly) { + using mesh::cli::TerminalChannelCommandMatch; + mesh::cli::TerminalChannelMessage message; + size_t channel_index = 99; + + EXPECT_EQ(TerminalChannelCommandMatch::Valid, + mesh::cli::parseTerminalChannelMessage( + "channel 2 Slot message", message)); + EXPECT_TRUE(mesh::cli::parseTerminalChannelIndex(message, 8, + channel_index)); + EXPECT_EQ(2u, channel_index); + + EXPECT_EQ(TerminalChannelCommandMatch::Valid, + mesh::cli::parseTerminalChannelMessage( + "channel 8 Out of range", message)); + EXPECT_FALSE(mesh::cli::parseTerminalChannelIndex(message, 8, + channel_index)); + + EXPECT_EQ(TerminalChannelCommandMatch::Valid, + mesh::cli::parseTerminalChannelMessage( + "channel 2name Named channel", message)); + EXPECT_FALSE(mesh::cli::parseTerminalChannelIndex(message, 8, + channel_index)); + EXPECT_TRUE(mesh::cli::terminalChannelNameMatches(message, "2name")); +} + +TEST(CLICommandUtils, RejectsIncompleteTerminalChannelMessages) { + using mesh::cli::TerminalChannelCommandMatch; + mesh::cli::TerminalChannelMessage message; + + EXPECT_EQ(TerminalChannelCommandMatch::MissingSelector, + mesh::cli::parseTerminalChannelMessage("channel", message)); + EXPECT_EQ(TerminalChannelCommandMatch::MissingMessage, + mesh::cli::parseTerminalChannelMessage("channel #rgdata", message)); + EXPECT_EQ(TerminalChannelCommandMatch::NoMatch, + mesh::cli::parseTerminalChannelMessage("channels", message)); + EXPECT_EQ(TerminalChannelCommandMatch::NoMatch, + mesh::cli::parseTerminalChannelMessage("channelized test", message)); +} + +TEST(CLICommandUtils, ParsesTerminalLoginAndRemoteCommandArguments) { + using mesh::cli::TerminalArgumentCommandMatch; + const char* argument = nullptr; + + EXPECT_EQ(TerminalArgumentCommandMatch::Valid, + mesh::cli::parseTerminalArgumentCommand( + "login admin password", "login", argument)); + EXPECT_STREQ("admin password", argument); + + EXPECT_EQ(TerminalArgumentCommandMatch::Valid, + mesh::cli::parseTerminalArgumentCommand( + "LOGIN CaseSensitivePassword", "login", argument)); + EXPECT_STREQ("CaseSensitivePassword", argument); + + EXPECT_EQ(TerminalArgumentCommandMatch::Valid, + mesh::cli::parseTerminalArgumentCommand( + "cmd\tget stats", "cmd", argument)); + EXPECT_STREQ("get stats", argument); + + EXPECT_EQ(TerminalArgumentCommandMatch::MissingArgument, + mesh::cli::parseTerminalArgumentCommand( + "login ", "login", argument)); + EXPECT_EQ(nullptr, argument); + EXPECT_EQ(TerminalArgumentCommandMatch::NoMatch, + mesh::cli::parseTerminalArgumentCommand( + "logins password", "login", argument)); +} + +TEST(CLICommandUtils, MasksOnlyTerminalLoginPasswordInput) { + EXPECT_FALSE(mesh::cli::shouldMaskTerminalInput("login")); + EXPECT_FALSE(mesh::cli::shouldMaskTerminalInput("login ")); + EXPECT_TRUE(mesh::cli::shouldMaskTerminalInput("login s")); + EXPECT_TRUE(mesh::cli::shouldMaskTerminalInput("LOGIN s")); + EXPECT_TRUE(mesh::cli::shouldMaskTerminalInput(" login secret phrase")); + EXPECT_FALSE(mesh::cli::shouldMaskTerminalInput("cmd login secret")); + EXPECT_FALSE(mesh::cli::shouldMaskTerminalInput("login-status")); +} + TEST(CLICommandUtils, ParsesStrictDecimalValues) { float value = 0.0f; diff --git a/test/test_trace_path_helpers/test_trace_path_helpers.cpp b/test/test_trace_path_helpers/test_trace_path_helpers.cpp new file mode 100644 index 00000000..0ff5d6a8 --- /dev/null +++ b/test/test_trace_path_helpers/test_trace_path_helpers.cpp @@ -0,0 +1,173 @@ +#include + +#include + +TEST(TracePathHelpers, BuildsOneByteRoundTripThroughEndpoint) { + const uint8_t saved[] = {0x11, 0x22}; + const uint8_t endpoint[] = {0x33}; + uint8_t route[8] = {0}; + mesh::RoundTripTracePath result; + + ASSERT_TRUE(mesh::buildRoundTripTracePath( + saved, 2, endpoint, true, route, sizeof(route), result)); + + const uint8_t expected[] = {0x11, 0x22, 0x33, 0x22, 0x11}; + EXPECT_EQ(1, result.hash_size); + EXPECT_EQ(5, result.hop_count); + EXPECT_EQ(sizeof(expected), result.byte_len); + EXPECT_EQ(0, memcmp(expected, route, sizeof(expected))); + EXPECT_EQ(0, mesh::traceFlagsForHashSize(result.hash_size)); +} + +TEST(TracePathHelpers, OmitsNonForwardingEndpoint) { + const uint8_t saved[] = {0x11, 0x22}; + uint8_t route[8] = {0}; + mesh::RoundTripTracePath result; + + ASSERT_TRUE(mesh::buildRoundTripTracePath( + saved, 2, nullptr, false, route, sizeof(route), result)); + + const uint8_t expected[] = {0x11, 0x22, 0x22, 0x11}; + EXPECT_EQ(4, result.hop_count); + EXPECT_EQ(sizeof(expected), result.byte_len); + EXPECT_EQ(0, memcmp(expected, route, sizeof(expected))); +} + +TEST(TracePathHelpers, PreservesTwoByteHashes) { + const uint8_t saved[] = {0x11, 0x12, 0x21, 0x22}; + const uint8_t endpoint[] = {0x31, 0x32}; + uint8_t route[16] = {0}; + mesh::RoundTripTracePath result; + + ASSERT_TRUE(mesh::buildRoundTripTracePath( + saved, static_cast((1 << 6) | 2), endpoint, true, + route, sizeof(route), result)); + + const uint8_t expected[] = { + 0x11, 0x12, 0x21, 0x22, 0x31, 0x32, 0x21, 0x22, 0x11, 0x12}; + EXPECT_EQ(2, result.hash_size); + EXPECT_EQ(5, result.hop_count); + EXPECT_EQ(sizeof(expected), result.byte_len); + EXPECT_EQ(0, memcmp(expected, route, sizeof(expected))); + EXPECT_EQ(1, mesh::traceFlagsForHashSize(result.hash_size)); +} + +TEST(TracePathHelpers, SafelyDownConvertsThreeByteHashes) { + const uint8_t saved[] = { + 0x11, 0x12, 0x13, 0x21, 0x22, 0x23}; + const uint8_t endpoint[] = {0x31, 0x32, 0x33}; + uint8_t route[16] = {0}; + mesh::RoundTripTracePath result; + + ASSERT_TRUE(mesh::buildRoundTripTracePath( + saved, static_cast((2 << 6) | 2), endpoint, true, + route, sizeof(route), result)); + + const uint8_t expected[] = { + 0x11, 0x12, 0x21, 0x22, 0x31, 0x32, 0x21, 0x22, 0x11, 0x12}; + EXPECT_EQ(2, result.hash_size); + EXPECT_EQ(5, result.hop_count); + EXPECT_EQ(sizeof(expected), result.byte_len); + EXPECT_EQ(0, memcmp(expected, route, sizeof(expected))); +} + +TEST(TracePathHelpers, RejectsUnknownEmptyAndOversizedRoutes) { + const uint8_t saved[] = {0x11, 0x22}; + const uint8_t endpoint[] = {0x33}; + uint8_t route[4] = {0}; + mesh::RoundTripTracePath result; + + EXPECT_FALSE(mesh::buildRoundTripTracePath( + saved, 0xFF, endpoint, true, route, sizeof(route), result)); + EXPECT_FALSE(mesh::buildRoundTripTracePath( + nullptr, 0, nullptr, false, route, sizeof(route), result)); + EXPECT_FALSE(mesh::buildRoundTripTracePath( + saved, 2, endpoint, true, route, sizeof(route), result)); +} + +TEST(TracePathHelpers, ParsesOneBytePrefixesSeparatedBySpaces) { + uint8_t route[8] = {0}; + mesh::RawTracePath result; + + ASSERT_EQ(mesh::RawTracePathParseResult::Valid, + mesh::parseRawTracePath("1 11 22 3a 22 11", route, + sizeof(route), 63, result)); + + const uint8_t expected[] = {0x11, 0x22, 0x3A, 0x22, 0x11}; + EXPECT_EQ(1, result.hash_size); + EXPECT_EQ(5, result.hop_count); + EXPECT_EQ(sizeof(expected), result.byte_len); + EXPECT_EQ(0, memcmp(expected, route, sizeof(expected))); +} + +TEST(TracePathHelpers, ParsesTwoBytePrefixesWithMixedSeparators) { + uint8_t route[16] = {0}; + mesh::RawTracePath result; + + ASSERT_EQ(mesh::RawTracePathParseResult::Valid, + mesh::parseRawTracePath("2 1122, 3344 ,AAbb,3344 1122", + route, sizeof(route), 63, result)); + + const uint8_t expected[] = { + 0x11, 0x22, 0x33, 0x44, 0xAA, 0xBB, 0x33, 0x44, 0x11, 0x22}; + EXPECT_EQ(2, result.hash_size); + EXPECT_EQ(5, result.hop_count); + EXPECT_EQ(sizeof(expected), result.byte_len); + EXPECT_EQ(0, memcmp(expected, route, sizeof(expected))); +} + +TEST(TracePathHelpers, ParsesFourBytePrefixesSeparatedByCommas) { + uint8_t route[16] = {0}; + mesh::RawTracePath result; + + ASSERT_EQ(mesh::RawTracePathParseResult::Valid, + mesh::parseRawTracePath("4,01234567,89abcdef,01234567,", + route, sizeof(route), 63, result)); + + const uint8_t expected[] = { + 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, + 0x01, 0x23, 0x45, 0x67}; + EXPECT_EQ(4, result.hash_size); + EXPECT_EQ(3, result.hop_count); + EXPECT_EQ(sizeof(expected), result.byte_len); + EXPECT_EQ(0, memcmp(expected, route, sizeof(expected))); + EXPECT_EQ(2, mesh::traceFlagsForHashSize(result.hash_size)); +} + +TEST(TracePathHelpers, RejectsThreeByteAndMalformedRawPaths) { + uint8_t route[8] = {0}; + mesh::RawTracePath result; + + EXPECT_EQ(mesh::RawTracePathParseResult::InvalidHashSize, + mesh::parseRawTracePath("3 112233", route, sizeof(route), 63, + result)); + EXPECT_EQ(mesh::RawTracePathParseResult::MissingHashSize, + mesh::parseRawTracePath(" ", route, sizeof(route), 63, result)); + EXPECT_EQ(mesh::RawTracePathParseResult::MissingPrefixes, + mesh::parseRawTracePath("2 , ", route, sizeof(route), 63, + result)); + EXPECT_EQ(mesh::RawTracePathParseResult::InvalidPrefix, + mesh::parseRawTracePath("2 123 4567", route, sizeof(route), 63, + result)); + EXPECT_EQ(mesh::RawTracePathParseResult::InvalidPrefix, + mesh::parseRawTracePath("1 GG", route, sizeof(route), 63, + result)); +} + +TEST(TracePathHelpers, EnforcesRawPathHopAndByteLimits) { + uint8_t route[8] = {0}; + mesh::RawTracePath result; + + EXPECT_EQ(mesh::RawTracePathParseResult::TooManyHops, + mesh::parseRawTracePath("1 11 22 33", route, sizeof(route), 2, + result)); + EXPECT_EQ(mesh::RawTracePathParseResult::RouteTooLong, + mesh::parseRawTracePath("4 11223344 55667788 99AABBCC", route, + sizeof(route), 63, result)); + EXPECT_EQ(0xFF, mesh::traceFlagsForHashSize(3)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}