diff --git a/patches/meshcore/0001-Add-companion-radio-firmware-bot-command-parity.patch b/patches/meshcore/0001-Add-companion-radio-firmware-bot-command-parity.patch index 2159434..76bd0a1 100644 --- a/patches/meshcore/0001-Add-companion-radio-firmware-bot-command-parity.patch +++ b/patches/meshcore/0001-Add-companion-radio-firmware-bot-command-parity.patch @@ -1,7 +1,7 @@ From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: cj-vana Date: Fri, 15 May 2026 11:44:21 -0600 -Subject: [PATCH 1/3] Add companion radio firmware bot command parity +Subject: [PATCH 1/5] Add companion radio firmware bot command parity --- .../companion_radio/BotCommandRegistry.cpp | 121 ++ diff --git a/patches/meshcore/0002-Harden-firmware-bot-response-coordination.patch b/patches/meshcore/0002-Harden-firmware-bot-response-coordination.patch index d26653e..a9b61a8 100644 --- a/patches/meshcore/0002-Harden-firmware-bot-response-coordination.patch +++ b/patches/meshcore/0002-Harden-firmware-bot-response-coordination.patch @@ -1,7 +1,7 @@ From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: cj-vana Date: Fri, 15 May 2026 19:58:42 -0600 -Subject: [PATCH 2/3] Harden firmware bot response coordination +Subject: [PATCH 2/5] Harden firmware bot response coordination --- examples/companion_radio/BotCommands.cpp | 2 +- diff --git a/patches/meshcore/0003-Allow-prefixless-firmware-bot-DMs.patch b/patches/meshcore/0003-Allow-prefixless-firmware-bot-DMs.patch index 658bf8c..60ff0b3 100644 --- a/patches/meshcore/0003-Allow-prefixless-firmware-bot-DMs.patch +++ b/patches/meshcore/0003-Allow-prefixless-firmware-bot-DMs.patch @@ -1,7 +1,7 @@ From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: cj-vana Date: Fri, 15 May 2026 20:09:58 -0600 -Subject: [PATCH 3/3] Allow prefixless firmware bot DMs +Subject: [PATCH 3/5] Allow prefixless firmware bot DMs --- examples/companion_radio/BotPolicy.cpp | 2 +- diff --git a/patches/meshcore/0004-Align-firmware-bot-commands-with-upstream-behavior.patch b/patches/meshcore/0004-Align-firmware-bot-commands-with-upstream-behavior.patch new file mode 100644 index 0000000..674ea34 --- /dev/null +++ b/patches/meshcore/0004-Align-firmware-bot-commands-with-upstream-behavior.patch @@ -0,0 +1,1140 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: cj-vana +Date: Sat, 16 May 2026 04:12:02 +0000 +Subject: [PATCH 4/5] Align firmware bot commands with upstream behavior + +--- + .../companion_radio/BotCommandRegistry.cpp | 25 +- + examples/companion_radio/BotCommands.cpp | 404 ++++++++++++++---- + examples/companion_radio/BotTypes.h | 2 + + examples/companion_radio/FirmwareBot.cpp | 83 +++- + examples/companion_radio/FirmwareBot.h | 2 + + examples/companion_radio/MyMesh.cpp | 170 ++++++-- + platformio.ini | 1 + + 7 files changed, 549 insertions(+), 138 deletions(-) + +diff --git a/examples/companion_radio/BotCommandRegistry.cpp b/examples/companion_radio/BotCommandRegistry.cpp +index 4fcf07a2..49888a7a 100644 +--- a/examples/companion_radio/BotCommandRegistry.cpp ++++ b/examples/companion_radio/BotCommandRegistry.cpp +@@ -10,12 +10,9 @@ const char* const kTestAliases[] = { "t" }; + const char* const kHelloAliases[] = { "hi" }; + const char* const kVersionAliases[] = { "ver" }; + const char* const kMagic8Aliases[] = { "8ball", "eightball" }; ++const char* const kChannelsAliases[] = { "channel" }; + const char* const kPathAliases[] = { "p", "decode", "route" }; +-const char* const kUnsupportedAliases[] = { +- "ack", "bbs", "blacklist", "delete", "download", "email", "heard", "history", "ignore", "info", +- "last", "lheard", "location", "map", "mqtt", "nodes", "reboot", "set", "sms", "telemetry", +- "upload", "weather", "whereami" +-}; ++const char* const kPrefixAliases[] = { "lookup" }; + + size_t boundedStrLen(const char* value, size_t max_len) { + size_t len = 0; +@@ -40,18 +37,18 @@ const BotCommandMetadata kCommands[] = { + { BOT_COMMAND_PING, "ping", NULL, 0, BOT_COMMAND_MASK_PING, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_NORMAL, "Check bot response", "ping", "Reply with Pong when the bot is alive." }, + { BOT_COMMAND_TEST, "test", kTestAliases, 1, BOT_COMMAND_MASK_TEST, BOT_COMMAND_VISIBILITY_DISCOVERABLE, +- BOT_COMMAND_CONTEXT_NORMAL, "Run a bot test", "test", "Return a short firmware bot self-test response." }, ++ BOT_COMMAND_CONTEXT_NORMAL, "Test connection", "test [phrase]", "Get test response with connection info" }, + { BOT_COMMAND_HELLO, "hello", kHelloAliases, 1, BOT_COMMAND_MASK_HELLO, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_NORMAL, "Greet from the node", "hello", "Reply with the local bot node name." }, + { BOT_COMMAND_ABOUT, "about", NULL, 0, BOT_COMMAND_MASK_ABOUT, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_NORMAL, "Describe this bot", "about", "Describe the local firmware bot." }, + { BOT_COMMAND_ROLL, "roll", NULL, 0, BOT_COMMAND_MASK_ROLL, BOT_COMMAND_VISIBILITY_DISCOVERABLE, +- BOT_COMMAND_CONTEXT_NORMAL, "Roll a numeric range", "roll [max|low high]", "Roll within a numeric range." }, ++ BOT_COMMAND_CONTEXT_NORMAL, "Roll a number", "roll [max|low high]", "Roll a random number between 1 and X, or between low and high." }, + { BOT_COMMAND_DICE, "dice", NULL, 0, BOT_COMMAND_MASK_DICE, BOT_COMMAND_VISIBILITY_DISCOVERABLE, +- BOT_COMMAND_CONTEXT_NORMAL, "Roll dice notation", "dice [dN|NdN]", "Roll dice notation with bounded dice and sides." }, ++ BOT_COMMAND_CONTEXT_NORMAL, "Roll dice", "dice [NdX|dX|decade]", "Roll dice for tabletop games using bounded D&D-style notation." }, + { BOT_COMMAND_STATUS, "status", NULL, 0, BOT_COMMAND_MASK_STATUS, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_DIAGNOSTIC, "Show node status", "status", "Show local uptime, battery, storage, and bot send counters." }, +- { BOT_COMMAND_CHANNELS, "channels", NULL, 0, BOT_COMMAND_MASK_CHANNELS, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ { BOT_COMMAND_CHANNELS, "channels", kChannelsAliases, 1, BOT_COMMAND_MASK_CHANNELS, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_DIAGNOSTIC, "Show configured channels", "channels", "Show local bot, testing, emergency, and public channel names." }, + { BOT_COMMAND_VERSION, "version", kVersionAliases, 1, BOT_COMMAND_MASK_VERSION, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_NORMAL, "Show firmware version", "version", "Show local firmware version and build date." }, +@@ -60,15 +57,13 @@ const BotCommandMetadata kCommands[] = { + { BOT_COMMAND_MAGIC8, "magic8", kMagic8Aliases, 2, BOT_COMMAND_MASK_MAGIC8, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_NORMAL, "Ask the magic 8-ball", "magic8 ", "Return a short pseudo-random magic 8-ball answer." }, + { BOT_COMMAND_PATH, "path", kPathAliases, 3, BOT_COMMAND_MASK_PATH, BOT_COMMAND_VISIBILITY_DISCOVERABLE, +- BOT_COMMAND_CONTEXT_TRACE, "Show last path", "path", "Show the latest local packet path hashes, hash width, and SNR if available." }, ++ BOT_COMMAND_CONTEXT_TRACE, "Show or decode path", "path [hex-path]", "Show or decode bounded packet path hashes using comma-separated or contiguous hex." }, + { BOT_COMMAND_TRACE, "trace", NULL, 0, BOT_COMMAND_MASK_TRACE, BOT_COMMAND_VISIBILITY_DISCOVERABLE, +- BOT_COMMAND_CONTEXT_TRACE, "Send active trace", "trace [hex-path]", "Send a bounded active trace request using local MeshCore state." }, ++ BOT_COMMAND_CONTEXT_TRACE, "Run link trace", "trace [path]", "Run link trace using local MeshCore state." }, + { BOT_COMMAND_TRACER, "tracer", NULL, 0, BOT_COMMAND_MASK_TRACER, BOT_COMMAND_VISIBILITY_DISCOVERABLE, +- BOT_COMMAND_CONTEXT_TRACE, "Show route trace", "tracer", "Show the current packet route hashes without sending an active trace." }, +- { BOT_COMMAND_PREFIX, "prefix", NULL, 0, BOT_COMMAND_MASK_PREFIX, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ BOT_COMMAND_CONTEXT_TRACE, "Run link trace", "tracer [path]", "Run link trace using reciprocal local MeshCore state." }, ++ { BOT_COMMAND_PREFIX, "prefix", kPrefixAliases, 1, BOT_COMMAND_MASK_PREFIX, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_LOCAL_CONTACT, "Look up local prefix", "prefix ", "Look up a local contact by public-key prefix using local firmware contacts only." }, +- { BOT_COMMAND_UNSUPPORTED, "unsupported", kUnsupportedAliases, 23, 0, BOT_COMMAND_VISIBILITY_HIDDEN, +- BOT_COMMAND_CONTEXT_UNSUPPORTED, "Unavailable in firmware", "", "This upstream host command is unavailable in firmware." }, + { BOT_COMMAND_UNKNOWN, "unknown", NULL, 0, 0, BOT_COMMAND_VISIBILITY_INTERNAL, + BOT_COMMAND_CONTEXT_INTERNAL, "Unknown command", "unknown", "Internal unknown-command handler." } + }; +diff --git a/examples/companion_radio/BotCommands.cpp b/examples/companion_radio/BotCommands.cpp +index add7feed..f3c9bc0e 100644 +--- a/examples/companion_radio/BotCommands.cpp ++++ b/examples/companion_radio/BotCommands.cpp +@@ -9,6 +9,28 @@ + + namespace { + ++const uint16_t kRollMax = 10000; ++const uint8_t kMaxDiceTerms = 10; ++ ++struct ParsedPathArg { ++ uint8_t bytes[BOT_MAX_PATH_BYTES]; ++ uint8_t byte_len; ++ uint8_t hash_size; ++ uint8_t hash_count; ++}; ++ ++struct DiceTerm { ++ uint8_t count; ++ uint16_t sides; ++}; ++ ++struct ParsedDice { ++ DiceTerm terms[kMaxDiceTerms]; ++ uint8_t term_count; ++ uint8_t total_count; ++ bool decade; ++}; ++ + BotCommandResult makeResult(BotCommandResultCode code, size_t text_len) { + BotCommandResult result = { code, text_len }; + return result; +@@ -59,18 +81,28 @@ void appendText(char* output, size_t output_len, size_t* pos, const char* text) + output[*pos < output_len ? *pos : output_len - 1] = 0; + } + ++void appendFormatted(char* output, size_t output_len, size_t* pos, const char* format, ...) { ++ char temp[32]; ++ va_list args; ++ va_start(args, format); ++ int n = vsnprintf(temp, sizeof(temp), format, args); ++ va_end(args); ++ if (n < 0) return; ++ appendText(output, output_len, pos, temp); ++} ++ + BotCommandResult resultForAppend(char* output, size_t output_len, size_t pos) { + if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); + size_t actual = boundedStrLen(output, output_len); + return makeResult(pos >= output_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, actual); + } + +-bool parseUInt(const char* text, size_t len, size_t* pos, uint16_t* value) { ++bool parseUInt(const char* text, size_t len, size_t* pos, uint16_t max_value, uint16_t* value) { + uint32_t parsed = 0; + size_t start = *pos; + while (*pos < len && isdigit((unsigned char)text[*pos])) { + parsed = parsed * 10 + (uint32_t)(text[*pos] - '0'); +- if (parsed > 1000) return false; ++ if (parsed > max_value) return false; + (*pos)++; + } + if (*pos == start) return false; +@@ -78,8 +110,17 @@ bool parseUInt(const char* text, size_t len, size_t* pos, uint16_t* value) { + return true; + } + +-bool isSupportedSides(uint16_t sides) { +- return sides >= 2 && sides <= 1000; ++bool textEqualsIgnoreCase(const char* text, size_t len, const char* expected) { ++ size_t expected_len = boundedStrLen(expected, BOT_MAX_COMMAND_ARGS_LEN + 1); ++ if (len != expected_len) return false; ++ for (size_t i = 0; i < len; i++) { ++ if (tolower((unsigned char)text[i]) != tolower((unsigned char)expected[i])) return false; ++ } ++ return true; ++} ++ ++bool isSupportedDiceSides(uint16_t sides) { ++ return sides == 4 || sides == 6 || sides == 8 || sides == 10 || sides == 12 || sides == 20 || sides == 100; + } + + void skipSpaces(const char* text, size_t len, size_t* pos) { +@@ -95,7 +136,7 @@ bool parseRoll(const BotCommand& command, uint16_t* low, uint16_t* high) { + size_t len = command.args_len; + size_t pos = 0; + uint16_t first = 0; +- if (!parseUInt(text, len, &pos, &first) || first == 0) return false; ++ if (!parseUInt(text, len, &pos, kRollMax, &first) || first == 0) return false; + + skipSpaces(text, len, &pos); + if (pos == len) { +@@ -104,7 +145,7 @@ bool parseRoll(const BotCommand& command, uint16_t* low, uint16_t* high) { + } + + uint16_t second = 0; +- if (!parseUInt(text, len, &pos, &second) || second == 0) return false; ++ if (!parseUInt(text, len, &pos, kRollMax, &second) || second == 0) return false; + skipSpaces(text, len, &pos); + if (pos != len || first > second) return false; + +@@ -113,26 +154,61 @@ bool parseRoll(const BotCommand& command, uint16_t* low, uint16_t* high) { + return true; + } + +-bool parseDice(const BotCommand& command, uint16_t* count, uint16_t* sides) { +- *count = 1; +- *sides = 6; +- if (command.args_len == 0) return true; ++bool parseDiceTerm(const char* text, size_t len, size_t* pos, DiceTerm* term) { ++ uint16_t first = 0; ++ term->count = 1; ++ term->sides = 6; ++ ++ if (*pos >= len) return false; ++ if (text[*pos] == 'd' || text[*pos] == 'D') { ++ (*pos)++; ++ if (!parseUInt(text, len, pos, 1000, &term->sides)) return false; ++ } else { ++ if (!parseUInt(text, len, pos, kRollMax, &first)) return false; ++ if (*pos < len && (text[*pos] == 'd' || text[*pos] == 'D')) { ++ if (first == 0 || first > 10) return false; ++ term->count = (uint8_t)first; ++ (*pos)++; ++ if (!parseUInt(text, len, pos, 1000, &term->sides)) return false; ++ } else { ++ term->sides = first; ++ } ++ } ++ ++ return term->count >= 1 && term->count <= 10 && isSupportedDiceSides(term->sides); ++} ++ ++bool parseDice(const BotCommand& command, ParsedDice* parsed) { ++ memset(parsed, 0, sizeof(*parsed)); ++ if (command.args_len == 0) { ++ parsed->terms[0].count = 1; ++ parsed->terms[0].sides = 6; ++ parsed->term_count = 1; ++ parsed->total_count = 1; ++ return true; ++ } + + const char* text = command.args; + size_t len = command.args_len; +- size_t pos = 0; ++ if (textEqualsIgnoreCase(text, len, "decade")) { ++ parsed->decade = true; ++ return true; ++ } + +- if (text[pos] == 'd' || text[pos] == 'D') { +- pos++; +- if (!parseUInt(text, len, &pos, sides)) return false; +- } else { +- if (!parseUInt(text, len, &pos, count)) return false; +- if (pos >= len || (text[pos] != 'd' && text[pos] != 'D')) return false; +- pos++; +- if (!parseUInt(text, len, &pos, sides)) return false; ++ size_t pos = 0; ++ while (pos < len) { ++ skipSpaces(text, len, &pos); ++ if (pos >= len) break; ++ if (parsed->term_count >= kMaxDiceTerms) return false; ++ DiceTerm* term = &parsed->terms[parsed->term_count]; ++ if (!parseDiceTerm(text, len, &pos, term)) return false; ++ parsed->total_count = (uint8_t)(parsed->total_count + term->count); ++ if (parsed->total_count > 10) return false; ++ parsed->term_count++; ++ if (pos < len && text[pos] != ' ') return false; + } + +- return pos == len && *count >= 1 && *count <= 10 && isSupportedSides(*sides); ++ return parsed->term_count > 0; + } + + uint16_t rollOnce(uint32_t* state, uint16_t sides) { +@@ -140,6 +216,75 @@ uint16_t rollOnce(uint32_t* state, uint16_t sides) { + return (uint16_t)((*state >> 16) % sides) + 1; + } + ++int hexValue(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; ++} ++ ++bool parseHexByte(const char* text, uint8_t* value) { ++ int hi = hexValue(text[0]); ++ int lo = hexValue(text[1]); ++ if (hi < 0 || lo < 0) return false; ++ *value = (uint8_t)((hi << 4) | lo); ++ return true; ++} ++ ++bool parsePathArgument(const char* text, size_t len, uint8_t configured_hash_size, ParsedPathArg* parsed) { ++ memset(parsed, 0, sizeof(*parsed)); ++ if (!text || len == 0) return false; ++ if (configured_hash_size != 0 && configured_hash_size != 1 && configured_hash_size != 2 && configured_hash_size != 4) return false; ++ ++ bool has_comma = false; ++ for (size_t i = 0; i < len; i++) { ++ if (text[i] == ',') has_comma = true; ++ } ++ ++ if (has_comma) { ++ size_t pos = 0; ++ size_t chunk_hex_len = 0; ++ while (pos < len) { ++ size_t start = pos; ++ while (pos < len && text[pos] != ',') { ++ if (hexValue(text[pos]) < 0) return false; ++ pos++; ++ } ++ size_t token_len = pos - start; ++ if (token_len != 2 && token_len != 4 && token_len != 8) return false; ++ if (configured_hash_size != 0 && token_len != (size_t)configured_hash_size * 2) return false; ++ if (chunk_hex_len == 0) chunk_hex_len = token_len; ++ if (token_len != chunk_hex_len) return false; ++ if ((size_t)parsed->byte_len + (token_len / 2) > sizeof(parsed->bytes)) return false; ++ for (size_t i = 0; i < token_len; i += 2) { ++ if (!parseHexByte(&text[start + i], &parsed->bytes[parsed->byte_len])) return false; ++ parsed->byte_len++; ++ } ++ parsed->hash_count++; ++ if (pos < len) { ++ pos++; ++ if (pos == len) return false; ++ } ++ } ++ parsed->hash_size = (uint8_t)(chunk_hex_len / 2); ++ return parsed->hash_size != 0 && parsed->hash_count != 0; ++ } ++ ++ if ((len % 2) != 0 || len / 2 > sizeof(parsed->bytes)) return false; ++ for (size_t i = 0; i < len; i++) { ++ if (hexValue(text[i]) < 0) return false; ++ } ++ parsed->byte_len = (uint8_t)(len / 2); ++ if (parsed->byte_len == 0) return false; ++ for (size_t i = 0; i < parsed->byte_len; i++) { ++ if (!parseHexByte(&text[i * 2], &parsed->bytes[i])) return false; ++ } ++ parsed->hash_size = configured_hash_size != 0 ? configured_hash_size : ((parsed->byte_len % 2) == 0 ? 2 : 1); ++ if ((parsed->byte_len % parsed->hash_size) != 0) return false; ++ parsed->hash_count = (uint8_t)(parsed->byte_len / parsed->hash_size); ++ return parsed->hash_count != 0; ++} ++ + void appendPathHex(char* output, size_t output_len, size_t* pos, const uint8_t* path, size_t path_len) { + static const char hex[] = "0123456789abcdef"; + for (size_t i = 0; i < path_len; i++) { +@@ -152,6 +297,14 @@ void appendPathHex(char* output, size_t output_len, size_t* pos, const uint8_t* + if (output_len > 0) output[*pos < output_len ? *pos : output_len - 1] = 0; + } + ++void appendPathHops(char* output, size_t output_len, size_t* pos, const uint8_t* path, uint8_t hash_size, ++ uint8_t hash_count) { ++ for (uint8_t hop = 0; hop < hash_count; hop++) { ++ if (hop != 0) appendText(output, output_len, pos, " -> "); ++ appendPathHex(output, output_len, pos, &path[(size_t)hop * hash_size], hash_size); ++ } ++} ++ + void formatQuarters(int8_t quarters, char* output, size_t output_len) { + if (!output || output_len == 0) return; + int value = quarters; +@@ -160,44 +313,49 @@ void formatQuarters(int8_t quarters, char* output, size_t output_len) { + snprintf(output, output_len, "%s%d.%02d", sign, value / 4, (value % 4) * 25); + } + +-BotCommandResult executeCmd(char* output, size_t output_len) { +- if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); +- output[0] = 0; +- size_t pos = 0; ++void appendCommandList(char* output, size_t output_len, size_t* pos, const BotCommandId* ids, size_t id_count) { + bool first = true; +- for (size_t i = 0; i < BotCommandRegistry::commandCount(); i++) { +- const BotCommandMetadata* command = BotCommandRegistry::commandAt(i); ++ for (size_t i = 0; i < id_count; i++) { ++ const BotCommandMetadata* command = BotCommandRegistry::findById(ids[i]); + if (!command || command->visibility != BOT_COMMAND_VISIBILITY_DISCOVERABLE) continue; +- if (!first) appendText(output, output_len, &pos, " "); +- appendText(output, output_len, &pos, command->name); ++ if (!first) appendText(output, output_len, pos, ", "); ++ appendText(output, output_len, pos, command->name); + first = false; + } ++} ++ ++BotCommandResult executeCmd(char* output, size_t output_len) { ++ static const BotCommandId ids[] = { ++ BOT_COMMAND_TEST, BOT_COMMAND_PING, BOT_COMMAND_HELP, BOT_COMMAND_HELLO, BOT_COMMAND_CMD, BOT_COMMAND_ABOUT, ++ BOT_COMMAND_ROLL, BOT_COMMAND_DICE, BOT_COMMAND_STATUS, BOT_COMMAND_CHANNELS, BOT_COMMAND_VERSION, ++ BOT_COMMAND_STATS, BOT_COMMAND_MAGIC8, BOT_COMMAND_PATH, BOT_COMMAND_TRACE, BOT_COMMAND_TRACER, ++ BOT_COMMAND_PREFIX ++ }; ++ if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ output[0] = 0; ++ size_t pos = 0; ++ appendText(output, output_len, &pos, "Available commands: "); ++ appendCommandList(output, output_len, &pos, ids, sizeof(ids) / sizeof(ids[0])); + return resultForAppend(output, output_len, pos); + } + + BotCommandResult executeHelp(const BotCommand& command, char* output, size_t output_len) { + if (command.args_len == 0) { ++ static const BotCommandId ids[] = { ++ BOT_COMMAND_HELP, BOT_COMMAND_CMD, BOT_COMMAND_PING, BOT_COMMAND_TEST, BOT_COMMAND_HELLO, BOT_COMMAND_ROLL, ++ BOT_COMMAND_DICE, BOT_COMMAND_TRACE, BOT_COMMAND_TRACER, BOT_COMMAND_PREFIX ++ }; + if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); + output[0] = 0; + size_t pos = 0; +- appendText(output, output_len, &pos, "Cmds: "); +- bool first = true; +- for (size_t i = 0; i < BotCommandRegistry::commandCount(); i++) { +- const BotCommandMetadata* metadata = BotCommandRegistry::commandAt(i); +- if (!metadata || metadata->visibility != BOT_COMMAND_VISIBILITY_DISCOVERABLE) continue; +- if (!first) appendText(output, output_len, &pos, " "); +- appendText(output, output_len, &pos, metadata->name); +- first = false; +- } ++ appendText(output, output_len, &pos, "Commands: "); ++ appendCommandList(output, output_len, &pos, ids, sizeof(ids) / sizeof(ids[0])); + appendText(output, output_len, &pos, "; help "); + return resultForAppend(output, output_len, pos); + } + + const BotCommandMetadata* metadata = BotCommandRegistry::findByName(command.args, command.args_len); + if (!metadata) return writeFormatted(output, output_len, "No help for %s", command.args); +- if (metadata->id == BOT_COMMAND_UNSUPPORTED) { +- return writeFormatted(output, output_len, "%s is unavailable in firmware", command.args); +- } + if (metadata->visibility != BOT_COMMAND_VISIBILITY_DISCOVERABLE) { + return writeFormatted(output, output_len, "%s is not available", command.args); + } +@@ -215,78 +373,157 @@ BotCommandResult executeMagic8(const BotCommandContext& context, char* output, s + } + + BotCommandResult executePathLike(const BotCommandContext& context, char* output, size_t output_len, const char* label) { +- if (!context.path || context.path_len == 0 || context.path_hash_count == 0 || context.path_hash_size == 0) { +- return writeFormatted(output, output_len, "%s unavailable", label); +- } ++ if (context.path_hash_size == 0) return writeFormatted(output, output_len, "%s unavailable", label); + if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); + +- size_t byte_len = (size_t)context.path_hash_size * context.path_hash_count; + char snr[8]; + formatQuarters(context.path_snr_quarters, snr, sizeof(snr)); +- int written = snprintf(output, output_len, "%s %uh x %uB snr %s: ", label, (unsigned)context.path_hash_count, +- (unsigned)context.path_hash_size, snr); ++ const char* target = context.response_target[0] ? context.response_target : NULL; ++ if (context.path_hash_count == 0 || context.path_len == 0) { ++ if (target) return writeFormatted(output, output_len, "%s @[%s] route: direct zero-hop, SNR %s", label, target, snr); ++ return writeFormatted(output, output_len, "%s route: direct zero-hop, SNR %s", label, snr); ++ } ++ if (!context.path) return writeFormatted(output, output_len, "%s unavailable", label); ++ ++ int written = target ? snprintf(output, output_len, "%s @[%s] route: %u hop%s, %u-byte hashes, SNR %s | ", label, ++ target, (unsigned)context.path_hash_count, ++ context.path_hash_count == 1 ? "" : "s", (unsigned)context.path_hash_size, snr) ++ : snprintf(output, output_len, "%s route: %u hop%s, %u-byte hashes, SNR %s | ", label, ++ (unsigned)context.path_hash_count, context.path_hash_count == 1 ? "" : "s", ++ (unsigned)context.path_hash_size, snr); ++ if (written < 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ size_t pos = (size_t)written; ++ appendPathHops(output, output_len, &pos, context.path, context.path_hash_size, context.path_hash_count); ++ size_t actual = boundedStrLen(output, output_len); ++ return makeResult(pos >= output_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, actual); ++} ++ ++BotCommandResult executePathArg(const BotCommand& command, const BotCommandContext& context, char* output, size_t output_len) { ++ ParsedPathArg path; ++ if (!parsePathArgument(command.args, command.args_len, context.path_hash_size, &path)) return writeText(output, output_len, "Usage: path [path]"); ++ if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ int written = snprintf(output, output_len, "Path route: %u hop%s, %u-byte hashes | ", (unsigned)path.hash_count, ++ path.hash_count == 1 ? "" : "s", (unsigned)path.hash_size); + if (written < 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); + size_t pos = (size_t)written; +- appendPathHex(output, output_len, &pos, context.path, byte_len); ++ appendPathHops(output, output_len, &pos, path.bytes, path.hash_size, path.hash_count); + size_t actual = boundedStrLen(output, output_len); + return makeResult(pos >= output_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, actual); + } + +-BotCommandResult executePath(const BotCommandContext& context, char* output, size_t output_len) { ++BotCommandResult executePath(const BotCommand& command, const BotCommandContext& context, char* output, size_t output_len) { ++ if (command.args_len != 0) return executePathArg(command, context, output, output_len); + return executePathLike(context, output, output_len, "Path"); + } + +-BotCommandResult executeTracer(const BotCommand& command, const BotCommandContext& context, char* output, size_t output_len) { +- if (command.args_len != 0) return writeText(output, output_len, "Usage: tracer"); +- return executePathLike(context, output, output_len, "Tracer"); ++BotCommandResult executeTraceLike(const BotCommand& command, const BotCommandContext& context, const char* label, ++ const char* usage, char* output, size_t output_len) { ++ if (command.args_len == 0) { ++ if (context.path_hash_size == 0) { ++ return writeFormatted(output, output_len, "%s needs a known route. Try path first.", label); ++ } ++ if (context.path_hash_count == 0 || context.path_len == 0) return writeFormatted(output, output_len, "%s pending on direct zero-hop route", label); ++ if (!context.path) return writeFormatted(output, output_len, "%s needs a known route. Try path first.", label); ++ return writeFormatted(output, output_len, "%s pending on %u-hop route", label, (unsigned)context.path_hash_count); ++ } ++ ParsedPathArg path; ++ if (!parsePathArgument(command.args, command.args_len, context.path_hash_size, &path)) { ++ return writeFormatted(output, output_len, "Usage: %s [path]", usage); ++ } ++ return writeFormatted(output, output_len, "%s pending on %u-hop route", label, (unsigned)path.hash_count); + } + + BotCommandResult executeRoll(const BotCommand& command, const BotCommandContext& context, char* output, size_t output_len) { + uint16_t low = 1; + uint16_t high = 100; +- if (!parseRoll(command, &low, &high)) return writeText(output, output_len, "Usage: roll [max|low high], range 1-1000"); ++ if (!parseRoll(command, &low, &high)) { ++ return writeText(output, output_len, "Invalid number. Use roll, roll X, or roll low high (max 10000)"); ++ } + + uint32_t state = context.random_seed ^ ((uint32_t)low << 16) ^ high; + if (state == 0) state = 1; + uint16_t span = (uint16_t)(high - low + 1); + uint16_t value = (uint16_t)(low + rollOnce(&state, span) - 1); +- return writeFormatted(output, output_len, "Rolled %u-%u: %u", (unsigned)low, (unsigned)high, (unsigned)value); ++ return writeFormatted(output, output_len, "Roll %u-%u: %u", (unsigned)low, (unsigned)high, (unsigned)value); ++} ++ ++void appendDiceRolls(char* output, size_t output_len, size_t* pos, uint32_t* state, const DiceTerm& term, uint16_t* total) { ++ if (term.count == 1) { ++ uint16_t roll = rollOnce(state, term.sides); ++ *total = (uint16_t)(*total + roll); ++ appendFormatted(output, output_len, pos, "d%u: %u", (unsigned)term.sides, (unsigned)roll); ++ return; ++ } ++ ++ appendFormatted(output, output_len, pos, "%ud%u: [", (unsigned)term.count, (unsigned)term.sides); ++ for (uint8_t i = 0; i < term.count; i++) { ++ uint16_t roll = rollOnce(state, term.sides); ++ *total = (uint16_t)(*total + roll); ++ if (i != 0) appendText(output, output_len, pos, ", "); ++ appendFormatted(output, output_len, pos, "%u", (unsigned)roll); ++ } ++ appendText(output, output_len, pos, "]"); + } + + BotCommandResult executeDice(const BotCommand& command, const BotCommandContext& context, char* output, size_t output_len) { +- uint16_t count = 1; +- uint16_t sides = 6; +- if (!parseDice(command, &count, &sides)) { +- return writeText(output, output_len, "Usage: dice [dN|NdN], max 10 dice, sides 2-1000"); ++ ParsedDice parsed; ++ if (!parseDice(command, &parsed)) { ++ return writeText(output, output_len, "Invalid dice type. Use dice, dice d20, dice 2d6, or dice decade"); + } + +- uint32_t state = context.random_seed ^ ((uint32_t)count << 16) ^ sides; ++ uint32_t state = context.random_seed ^ 0x9E3779B9UL; + if (state == 0) state = 1; + +- if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); +- int written = count == 1 ? snprintf(output, output_len, "Dice d%u: ", (unsigned)sides) +- : snprintf(output, output_len, "Dice %ud%u: ", (unsigned)count, (unsigned)sides); +- if (written < 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ if (parsed.decade) { ++ uint16_t value = (uint16_t)(rollOnce(&state, 10) * 10); ++ return writeFormatted(output, output_len, "Dice decade: %u", (unsigned)value); ++ } + +- size_t pos = (size_t)written; ++ if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ output[0] = 0; ++ size_t pos = 0; + uint16_t total = 0; +- for (uint16_t i = 0; i < count; i++) { +- uint16_t roll = rollOnce(&state, sides); +- total += roll; +- if (pos < output_len) { +- int n = snprintf(&output[pos], output_len - pos, i == 0 ? "%u" : "+%u", (unsigned)roll); +- if (n < 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); +- pos += (size_t)n; +- } ++ appendText(output, output_len, &pos, "Dice "); ++ ++ if (parsed.term_count == 1 && parsed.terms[0].count == 1) { ++ uint16_t roll = rollOnce(&state, parsed.terms[0].sides); ++ return writeFormatted(output, output_len, "Dice d%u: %u", (unsigned)parsed.terms[0].sides, (unsigned)roll); + } +- if (count > 1 && pos < output_len) { +- int n = snprintf(&output[pos], output_len - pos, "=%u", (unsigned)total); +- if (n < 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); +- pos += (size_t)n; ++ ++ if (parsed.term_count == 1) { ++ appendDiceRolls(output, output_len, &pos, &state, parsed.terms[0], &total); ++ appendFormatted(output, output_len, &pos, " = %u", (unsigned)total); ++ return resultForAppend(output, output_len, pos); + } + +- size_t actual = boundedStrLen(output, output_len); +- return makeResult(pos >= output_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, actual); ++ for (uint8_t i = 0; i < parsed.term_count; i++) { ++ if (i != 0) appendText(output, output_len, &pos, " + "); ++ appendDiceRolls(output, output_len, &pos, &state, parsed.terms[i], &total); ++ } ++ appendFormatted(output, output_len, &pos, " | Total: %u", (unsigned)total); ++ return resultForAppend(output, output_len, pos); ++} ++ ++void formatSecondsHms(uint32_t timestamp, char* output, size_t output_len) { ++ if (!output || output_len == 0) return; ++ if (timestamp == 0) { ++ snprintf(output, output_len, "Unknown"); ++ return; ++ } ++ uint32_t seconds = timestamp % 86400UL; ++ snprintf(output, output_len, "%02lu:%02lu:%02lu", (unsigned long)(seconds / 3600UL), ++ (unsigned long)((seconds / 60UL) % 60UL), (unsigned long)(seconds % 60UL)); ++} ++ ++BotCommandResult executeTest(const BotCommand& command, const BotCommandContext& context, char* output, size_t output_len) { ++ const char* name = context.node_name[0] ? context.node_name : "local"; ++ char received_at[9]; ++ formatSecondsHms(context.uptime_seconds, received_at, sizeof(received_at)); ++ if (command.args_len == 0) { ++ return writeFormatted(output, output_len, "ack @[%s] | local | Received at: %s", name, received_at); ++ } ++ return writeFormatted(output, output_len, "ack @[%s] %s | local | Received at: %s", name, command.args, ++ received_at); + } + + } +@@ -303,7 +540,7 @@ BotCommandResult executeCommand(const BotCommand& command, const BotCommandConte + case BOT_COMMAND_PING: + return writeText(output, output_len, "Pong!"); + case BOT_COMMAND_TEST: +- return writeText(output, output_len, "Bot test OK"); ++ return executeTest(command, context, output, output_len); + case BOT_COMMAND_HELLO: + return writeFormatted(output, output_len, "Hello from %s", context.node_name[0] ? context.node_name : "MeshCore bot"); + case BOT_COMMAND_ABOUT: +@@ -337,16 +574,15 @@ BotCommandResult executeCommand(const BotCommand& command, const BotCommandConte + case BOT_COMMAND_MAGIC8: + return executeMagic8(context, output, output_len); + case BOT_COMMAND_PATH: +- return executePath(context, output, output_len); ++ return executePath(command, context, output, output_len); + case BOT_COMMAND_TRACE: +- return command.args_len == 0 ? writeText(output, output_len, "Trace route unavailable") +- : writeText(output, output_len, "Usage: trace [hex-path]"); ++ return executeTraceLike(command, context, "Trace", "trace", output, output_len); + case BOT_COMMAND_TRACER: +- return executeTracer(command, context, output, output_len); ++ return executeTraceLike(command, context, "Tracer", "tracer", output, output_len); + case BOT_COMMAND_PREFIX: +- return writeText(output, output_len, "Prefix lookup unavailable"); ++ return writeText(output, output_len, "Prefix lookup requires local contacts"); + case BOT_COMMAND_UNSUPPORTED: +- return writeFormatted(output, output_len, "%s is unavailable in firmware", command.name); ++ return writeText(output, output_len, "Unknown command. Try help"); + case BOT_COMMAND_UNKNOWN: + return writeText(output, output_len, "Unknown command. Try help"); + default: +diff --git a/examples/companion_radio/BotTypes.h b/examples/companion_radio/BotTypes.h +index 4d48c13f..f93cae69 100644 +--- a/examples/companion_radio/BotTypes.h ++++ b/examples/companion_radio/BotTypes.h +@@ -174,6 +174,7 @@ struct BotMessage { + uint8_t sender_key_prefix_len; + bool text_truncated; + uint32_t sender_timestamp; ++ uint32_t received_at_timestamp; + uint8_t path_len; + uint8_t path_hash_size; + uint8_t path_hash_count; +@@ -238,6 +239,7 @@ struct BotCommandContext { + uint8_t path_hash_size; + uint8_t path_hash_count; + int8_t path_snr_quarters; ++ char response_target[BOT_MAX_SENDER_NAME_LEN + 1]; + const uint8_t* path; + }; + +diff --git a/examples/companion_radio/FirmwareBot.cpp b/examples/companion_radio/FirmwareBot.cpp +index 1eed310a..de6dd92f 100644 +--- a/examples/companion_radio/FirmwareBot.cpp ++++ b/examples/companion_radio/FirmwareBot.cpp +@@ -3,8 +3,13 @@ + #include "BotCommandRegistry.h" + + #include ++#include + #include + ++#ifndef BOT_LOCAL_TIME_OFFSET_SECONDS ++#define BOT_LOCAL_TIME_OFFSET_SECONDS -21600 ++#endif ++ + namespace { + + uint64_t fnv1aUpdate(uint64_t hash, uint8_t value) { +@@ -67,6 +72,18 @@ size_t boundedStrLen(const char* value, size_t max_len) { + return len; + } + ++void formatTimestampHms(uint32_t timestamp, char* output, size_t output_len) { ++ if (!output || output_len == 0) return; ++ if (timestamp == 0) { ++ snprintf(output, output_len, "Unknown"); ++ return; ++ } ++ int64_t adjusted = (int64_t)timestamp + (int64_t)BOT_LOCAL_TIME_OFFSET_SECONDS; ++ uint32_t seconds = (uint32_t)((adjusted % 86400LL + 86400LL) % 86400LL); ++ snprintf(output, output_len, "%02lu:%02lu:%02lu", (unsigned long)(seconds / 3600UL), ++ (unsigned long)((seconds / 60UL) % 60UL), (unsigned long)(seconds % 60UL)); ++} ++ + } + + namespace FirmwareBot { +@@ -114,6 +131,10 @@ BotCommandId commandIdForName(const char* name, size_t len) { + return command ? command->id : BOT_COMMAND_UNKNOWN; + } + ++static BotCommandId cooldownKeyFor(BotCommandId command_id) { ++ return command_id == BOT_COMMAND_TRACER ? BOT_COMMAND_TRACE : command_id; ++} ++ + size_t maxResponseLenForChannel(BotChannelKind channel_kind) { + return channel_kind == BOT_CHANNEL_DM ? BOT_MAX_RESPONSE_LEN : BOT_MAX_GROUP_RESPONSE_LEN; + } +@@ -121,8 +142,9 @@ size_t maxResponseLenForChannel(BotChannelKind channel_kind) { + bool isCommandOnCooldown(const BotCommandCooldown* cooldowns, size_t cooldown_count, BotCommandId command_id, + uint32_t now_millis) { + if (!cooldowns || command_id == BOT_COMMAND_NONE) return false; ++ BotCommandId cooldown_key = cooldownKeyFor(command_id); + for (size_t i = 0; i < cooldown_count; i++) { +- if (cooldowns[i].command_id == command_id && (int32_t)(cooldowns[i].expires_at_millis - now_millis) > 0) return true; ++ if (cooldowns[i].command_id == cooldown_key && (int32_t)(cooldowns[i].expires_at_millis - now_millis) > 0) return true; + } + return false; + } +@@ -130,10 +152,11 @@ bool isCommandOnCooldown(const BotCommandCooldown* cooldowns, size_t cooldown_co + void recordCommandCooldown(BotCommandCooldown* cooldowns, size_t cooldown_count, BotCommandId command_id, + uint32_t now_millis, uint32_t cooldown_millis) { + if (!cooldowns || cooldown_count == 0 || command_id == BOT_COMMAND_NONE || cooldown_millis == 0) return; ++ BotCommandId cooldown_key = cooldownKeyFor(command_id); + + size_t slot = cooldown_count; + for (size_t i = 0; i < cooldown_count; i++) { +- if (cooldowns[i].command_id == command_id) { ++ if (cooldowns[i].command_id == cooldown_key) { + slot = i; + break; + } +@@ -144,7 +167,7 @@ void recordCommandCooldown(BotCommandCooldown* cooldowns, size_t cooldown_count, + } + if (slot == cooldown_count) slot = 0; + +- cooldowns[slot].command_id = command_id; ++ cooldowns[slot].command_id = cooldown_key; + cooldowns[slot].expires_at_millis = now_millis + cooldown_millis; + } + +@@ -276,6 +299,60 @@ BotWriteResult writeResponseForChannel(BotChannelKind channel_kind, bool allow_p + return result; + } + ++BotWriteResult writeAckResponse(const BotMessage& message, const BotCommand& command, char* output, size_t output_len, ++ size_t* written) { ++ if (written) *written = 0; ++ if (!output || output_len == 0) return BOT_WRITE_NO_SPACE; ++ ++ const char* sender = message.sender_name[0] ? message.sender_name : "unknown"; ++ const char* connection = message.channel_kind == BOT_CHANNEL_DM ? "direct" : "channel"; ++ int n = command.args_len > 0 ? snprintf(output, output_len, "ack @[%s] %s | %s", sender, command.args, connection) ++ : snprintf(output, output_len, "ack @[%s] | %s", sender, connection); ++ if (n < 0) { ++ output[0] = 0; ++ return BOT_WRITE_NO_SPACE; ++ } ++ size_t pos = (size_t)n; ++ bool truncated = pos >= output_len; ++ if (truncated) pos = output_len - 1; ++ ++ if (message.path_hash_count > 0 && message.path_hash_size > 0) { ++ char path_info[36]; ++ int value = message.packet_snr_quarters; ++ const char* sign = value < 0 ? "-" : ""; ++ if (value < 0) value = -value; ++ int path_n = snprintf(path_info, sizeof(path_info), " path %uh x %uB snr %s%d.%02d", ++ (unsigned)message.path_hash_count, (unsigned)message.path_hash_size, sign, value / 4, ++ (value % 4) * 25); ++ if (path_n > 0) { ++ size_t path_len = (size_t)path_n; ++ size_t available = pos + 1 < output_len ? output_len - 1 - pos : 0; ++ size_t copy_len = path_len < available ? path_len : available; ++ if (copy_len > 0) memcpy(&output[pos], path_info, copy_len); ++ pos += path_len; ++ if (path_len > available) truncated = true; ++ output[pos < output_len ? pos : output_len - 1] = 0; ++ } ++ } ++ ++ char received_at[9]; ++ formatTimestampHms(message.received_at_timestamp, received_at, sizeof(received_at)); ++ char received[32]; ++ int received_n = snprintf(received, sizeof(received), " | Received at: %s", received_at); ++ if (received_n < 0) return BOT_WRITE_NO_SPACE; ++ size_t received_len = (size_t)received_n; ++ size_t available = pos + 1 < output_len ? output_len - 1 - pos : 0; ++ size_t copy_len = received_len < available ? received_len : available; ++ if (copy_len > 0) memcpy(&output[pos], received, copy_len); ++ pos += received_len; ++ if (received_len > available) truncated = true; ++ output[pos < output_len ? pos : output_len - 1] = 0; ++ ++ size_t actual = boundedStrLen(output, output_len); ++ if (written) *written = actual; ++ return truncated ? BOT_WRITE_TRUNCATED : BOT_WRITE_OK; ++} ++ + BotWriteResult writeBotAdvertName(const char* node_name, char* output, size_t output_len, size_t* written) { + if (written) *written = 0; + if (!output || output_len == 0) return BOT_WRITE_NO_SPACE; +diff --git a/examples/companion_radio/FirmwareBot.h b/examples/companion_radio/FirmwareBot.h +index 38d4416d..9eff340b 100644 +--- a/examples/companion_radio/FirmwareBot.h ++++ b/examples/companion_radio/FirmwareBot.h +@@ -14,6 +14,8 @@ bool splitChannelText(const char* text, size_t text_len, char* sender, size_t se + BotWriteResult writeResponse(char* output, size_t output_len, const char* text, size_t text_len, size_t* written); + BotWriteResult writeResponseForChannel(BotChannelKind channel_kind, bool allow_prefixless, const char* text, + size_t text_len, char* output, size_t output_len, size_t* written); ++BotWriteResult writeAckResponse(const BotMessage& message, const BotCommand& command, char* output, size_t output_len, ++ size_t* written); + BotWriteResult writeBotAdvertName(const char* node_name, char* output, size_t output_len, size_t* written); + bool isBotAdvertName(const char* name, size_t name_len); + BotFingerprint fingerprintFor(const BotMessage& message); +diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp +index 2ee5139b..7b87f14f 100644 +--- a/examples/companion_radio/MyMesh.cpp ++++ b/examples/companion_radio/MyMesh.cpp +@@ -223,7 +223,7 @@ static uint8_t botTraceHashSize(uint8_t flags) { + + static bool botTracePathShapeValid(uint8_t path_len, uint8_t flags) { + uint8_t hash_size = botTraceHashSize(flags); +- return path_len > 0 && path_len <= BOT_MAX_PATH_BYTES && path_len + 9 <= MAX_PACKET_PAYLOAD && ++ return path_len <= BOT_MAX_PATH_BYTES && path_len + 9 <= MAX_PACKET_PAYLOAD && + (path_len % hash_size) == 0 && (path_len / hash_size) <= MAX_PATH_SIZE; + } + +@@ -236,17 +236,42 @@ static void botCopyReversedPath(uint8_t *dest, const uint8_t *src, uint8_t hop_c + static bool botParseTraceHexPath(const BotCommand &command, uint8_t flags, uint8_t path[BOT_MAX_PATH_BYTES], + uint8_t *path_len) { + if (!path || !path_len || command.args_len == 0) return false; +- if ((command.args_len & 1) != 0 || command.args_len / 2 > BOT_MAX_PATH_BYTES) return false; + +- uint8_t parsed_len = (uint8_t)(command.args_len / 2); +- if (!botTracePathShapeValid(parsed_len, flags)) return false; +- +- for (uint8_t i = 0; i < parsed_len; i++) { +- int high = botHexValue(command.args[i * 2]); +- int low = botHexValue(command.args[i * 2 + 1]); +- if (high < 0 || low < 0) return false; +- path[i] = (uint8_t)((high << 4) | low); ++ uint8_t hash_size = botTraceHashSize(flags); ++ bool comma = false; ++ for (size_t i = 0; i < command.args_len; i++) { ++ if (command.args[i] == ',') comma = true; ++ } ++ ++ uint8_t parsed_len = 0; ++ if (comma) { ++ size_t pos = 0; ++ while (pos < command.args_len) { ++ if (pos + (hash_size * 2) > command.args_len) return false; ++ for (uint8_t i = 0; i < hash_size; i++) { ++ int high = botHexValue(command.args[pos + i * 2]); ++ int low = botHexValue(command.args[pos + i * 2 + 1]); ++ if (high < 0 || low < 0 || parsed_len >= BOT_MAX_PATH_BYTES) return false; ++ path[parsed_len++] = (uint8_t)((high << 4) | low); ++ } ++ pos += hash_size * 2; ++ if (pos == command.args_len) break; ++ if (command.args[pos] != ',') return false; ++ pos++; ++ if (pos == command.args_len) return false; ++ } ++ } else { ++ if ((command.args_len & 1) != 0 || command.args_len / 2 > BOT_MAX_PATH_BYTES) return false; ++ parsed_len = (uint8_t)(command.args_len / 2); ++ for (uint8_t i = 0; i < parsed_len; i++) { ++ int high = botHexValue(command.args[i * 2]); ++ int low = botHexValue(command.args[i * 2 + 1]); ++ if (high < 0 || low < 0) return false; ++ path[i] = (uint8_t)((high << 4) | low); ++ } + } ++ ++ if (!botTracePathShapeValid(parsed_len, flags)) return false; + *path_len = parsed_len; + return true; + } +@@ -284,7 +309,7 @@ static BotCommandResult botWriteFormatted(char *output, size_t output_len, const + static bool botParsePubKeyPrefixHex(const BotCommand &command, uint8_t prefix[PUB_KEY_SIZE], uint8_t *prefix_len) { + if (!prefix || !prefix_len || command.args_len == 0 || (command.args_len & 1) != 0) return false; + size_t byte_len = command.args_len / 2; +- if (byte_len < BOT_MIN_AUTH_SENDER_KEY_PREFIX_LEN || byte_len > PUB_KEY_SIZE) return false; ++ if (byte_len < 1 || byte_len > PUB_KEY_SIZE) return false; + for (size_t i = 0; i < byte_len; i++) { + int high = botHexValue(command.args[i * 2]); + int low = botHexValue(command.args[i * 2 + 1]); +@@ -341,14 +366,32 @@ static void botAppendHex(char *output, size_t output_len, size_t *pos, const uin + if (output_len > 0) output[*pos < output_len ? *pos : output_len - 1] = 0; + } + +-static size_t botFormatTraceSent(char *output, size_t output_len) { ++static void botAppendLiteral(char *output, size_t output_len, size_t *pos, const char *text) { ++ if (!output || output_len == 0 || !pos || !text) return; ++ for (size_t i = 0; text[i] != 0; i++) { ++ if (*pos + 1 < output_len) output[*pos] = text[i]; ++ (*pos)++; ++ } ++ output[*pos < output_len ? *pos : output_len - 1] = 0; ++} ++ ++static void botAppendPathHops(char *output, size_t output_len, size_t *pos, const uint8_t *path, uint8_t hash_size, ++ uint8_t hash_count) { ++ for (uint8_t hop = 0; hop < hash_count; hop++) { ++ if (hop != 0) botAppendLiteral(output, output_len, pos, " -> "); ++ botAppendHex(output, output_len, pos, &path[(size_t)hop * hash_size], hash_size); ++ } ++} ++ ++static size_t botFormatTraceSent(char *output, size_t output_len, uint8_t hop_count) { + if (!output || output_len == 0) return 0; +- const char *text = "Trace sent"; +- size_t len = botBoundedStrLen(text, BOT_MAX_RESPONSE_LEN + 1); +- if (len + 1 > output_len) len = output_len - 1; +- memcpy(output, text, len); +- output[len] = 0; +- return len; ++ int written = hop_count == 0 ? snprintf(output, output_len, "Trace sent on direct zero-hop route") ++ : snprintf(output, output_len, "Trace sent on %u-hop route", (unsigned)hop_count); ++ if (written < 0) { ++ output[0] = 0; ++ return 0; ++ } ++ return botBoundedStrLen(output, output_len); + } + + static size_t botFormatTraceResult(char *output, size_t output_len, uint32_t tag, uint8_t flags, +@@ -358,14 +401,22 @@ static size_t botFormatTraceResult(char *output, size_t output_len, uint32_t tag + uint8_t hop_count = path_len / hash_size; + char snr[8]; + botFormatQuarters(snr_quarters, snr, sizeof(snr)); +- int written = snprintf(output, output_len, "Trace %08lx %uh x %uB snr %s: ", (unsigned long)tag, +- (unsigned)hop_count, (unsigned)hash_size, snr); ++ if (hop_count == 0) { ++ int direct_written = snprintf(output, output_len, "Trace %08lx: direct zero-hop, SNR %s", (unsigned long)tag, snr); ++ if (direct_written < 0) { ++ output[0] = 0; ++ return 0; ++ } ++ return botBoundedStrLen(output, output_len); ++ } ++ int written = snprintf(output, output_len, "Trace %08lx: %u hop%s, SNR %s | ", (unsigned long)tag, ++ (unsigned)hop_count, hop_count == 1 ? "" : "s", snr); + if (written < 0) { + output[0] = 0; + return 0; + } + size_t pos = (size_t)written; +- botAppendHex(output, output_len, &pos, path_hashes, path_len); ++ botAppendPathHops(output, output_len, &pos, path_hashes, hash_size, hop_count); + return botBoundedStrLen(output, output_len); + } + #endif +@@ -1032,6 +1083,19 @@ void MyMesh::observeBotDirectMessage(const ContactInfo &from, uint32_t sender_ti + if (sender_prefix && prefix_len > 0) memcpy(message.sender_key_prefix, sender_prefix, prefix_len); + message.sender_key_prefix_len = prefix_len; + message.sender_timestamp = sender_timestamp; ++ message.received_at_timestamp = getRTCClock()->getCurrentTime(); ++ message.packet_snr_quarters = (int8_t)0; ++ if (from.out_path_len != OUT_PATH_UNKNOWN && mesh::Packet::isValidPathLen(from.out_path_len)) { ++ message.path_len = from.out_path_len; ++ message.path_hash_size = (from.out_path_len >> 6) + 1; ++ message.path_hash_count = from.out_path_len & 63; ++ message.path = from.out_path; ++ } else { ++ message.path_len = 0; ++ message.path_hash_size = botConfiguredTraceHashSize(_prefs.path_hash_mode); ++ message.path_hash_count = 0; ++ message.path = NULL; ++ } + message.text_truncated = FirmwareBot::normalizeText(text, botBoundedStrLen(text, BOT_MAX_TEXT_LEN + 1), message.text, + sizeof(message.text), &message.text_len) == BOT_WRITE_TRUNCATED; + recordBotObservation(message, &from, 0xFF); +@@ -1048,6 +1112,7 @@ void MyMesh::observeBotChannelMessage(uint8_t channel_idx, const char *channel_n + message.channel_name[channel_len] = 0; + } + message.sender_timestamp = sender_timestamp; ++ message.received_at_timestamp = getRTCClock()->getCurrentTime(); + if (packet && packet->isRouteFlood() && packet->path_len <= 0xFF && mesh::Packet::isValidPathLen((uint8_t)packet->path_len)) { + message.path_len = (uint8_t)packet->path_len; + message.path_hash_size = packet->getPathHashSize(); +@@ -1178,7 +1243,7 @@ BotCommandResult MyMesh::executeBotPrefixCommand(const BotCommand &command, char + uint8_t prefix[PUB_KEY_SIZE]; + uint8_t prefix_len = 0; + if (!botParsePubKeyPrefixHex(command, prefix, &prefix_len)) { +- return botWriteText(output, output_len, "Usage: prefix <8+ even hex>"); ++ return botWriteText(output, output_len, "Usage: prefix "); + } + + ContactInfo match; +@@ -1217,10 +1282,17 @@ bool MyMesh::handleBotTraceCommand(const BotMessage &message, const ContactInfo + bool have_path = false; + if (command.args_len > 0) { + have_path = botParseTraceHexPath(command, flags, path, &path_len); +- } else if (message.path && message.path_hash_count > 0) { ++ } else if (message.path_hash_size == hash_size) { + uint8_t raw_len = (uint8_t)(message.path_hash_count * hash_size); + if (botTracePathShapeValid(raw_len, flags)) { +- botCopyReversedPath(path, message.path, message.path_hash_count, hash_size); ++ if (raw_len > 0) { ++ if (!message.path) return false; ++ if (message.channel_kind == BOT_CHANNEL_DM) { ++ memcpy(path, message.path, raw_len); ++ } else { ++ botCopyReversedPath(path, message.path, message.path_hash_count, hash_size); ++ } ++ } + path_len = raw_len; + have_path = true; + } +@@ -1230,7 +1302,7 @@ bool MyMesh::handleBotTraceCommand(const BotMessage &message, const ContactInfo + + BotFingerprint request_fingerprint = FirmwareBot::fingerprintFor(message); + char response[BOT_MAX_RESPONSE_LEN + 1]; +- size_t response_len = botFormatTraceSent(response, sizeof(response)); ++ size_t response_len = botFormatTraceSent(response, sizeof(response), (uint8_t)(path_len / hash_size)); + char final_response[BOT_MAX_RESPONSE_LEN + 1]; + size_t final_response_len = 0; + if (!botFormatResponseForChannel(message, response, response_len, final_response, sizeof(final_response), +@@ -1247,7 +1319,7 @@ bool MyMesh::handleBotTraceCommand(const BotMessage &message, const ContactInfo + uint32_t jitter_seed = request_fingerprint.value ? (uint32_t)request_fingerprint.value : 1; + uint8_t queue_depth = (uint8_t)_mgr->getOutboundTotal(); + BotCoordinatorScheduleResult schedule = ResponseCoordinator::schedule(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS, +- message, BOT_COMMAND_TRACE, request_fingerprint, ++ message, command.id, request_fingerprint, + response_fingerprint, now, jitter_seed, + bot_identity_seed, queue_depth, bot_prefs.normal_delay_ms, + bot_prefs.normal_jitter_ms, &fingerprint, +@@ -1264,7 +1336,7 @@ bool MyMesh::handleBotTraceCommand(const BotMessage &message, const ContactInfo + + bot_stats.eligible_messages++; + bot_stats.pending_responses++; +- FirmwareBot::recordCommandCooldown(bot_command_cooldowns, BOT_COMMAND_COOLDOWN_SLOTS, BOT_COMMAND_TRACE, now, ++ FirmwareBot::recordCommandCooldown(bot_command_cooldowns, BOT_COMMAND_COOLDOWN_SLOTS, command.id, now, + BOT_TRACE_COOLDOWN_MILLIS); + if (!enqueueBotTrace(message, direct_recipient, channel_idx, path, path_len, flags, fingerprint, response_fingerprint, tag, + auth_code)) { +@@ -1278,7 +1350,7 @@ bool MyMesh::enqueueBotTrace(const BotMessage &message, const ContactInfo *direc + const uint8_t *path, uint8_t path_len, uint8_t flags, + BotFingerprint request_fingerprint, BotFingerprint response_fingerprint, uint32_t tag, + uint32_t auth_code) { +- if (!path || !botTracePathShapeValid(path_len, flags)) return false; ++ if ((path_len > 0 && !path) || !botTracePathShapeValid(path_len, flags)) return false; + + size_t slot = BOT_PENDING_TRACE_SLOTS; + for (size_t i = 0; i < BOT_PENDING_TRACE_SLOTS; i++) { +@@ -1308,7 +1380,7 @@ bool MyMesh::enqueueBotTrace(const BotMessage &message, const ContactInfo *direc + pending->auth_code = auth_code; + pending->flags = flags; + pending->path_len = path_len; +- memcpy(pending->path, path, path_len); ++ if (path_len > 0) memcpy(pending->path, path, path_len); + pending->active = true; + return true; + } +@@ -1372,7 +1444,7 @@ bool MyMesh::sendPendingBotTrace(PendingBotTrace &pending, uint32_t now_millis) + pending.expires_at_millis = now_millis + BOT_TRACE_TIMEOUT_MILLIS; + + char response[BOT_MAX_RESPONSE_LEN + 1]; +- size_t response_len = botFormatTraceSent(response, sizeof(response)); ++ size_t response_len = botFormatTraceSent(response, sizeof(response), pending.path_len / botTraceHashSize(pending.flags)); + char final_response[BOT_MAX_RESPONSE_LEN + 1]; + size_t final_response_len = 0; + if (!botFormatResponseForChannelKind(pending.channel_kind, response, response_len, final_response, sizeof(final_response), +@@ -1387,7 +1459,9 @@ void MyMesh::expirePendingBotTraces(uint32_t now_millis) { + PendingBotTrace *pending = &pending_bot_traces[i]; + if (!pending->active || !pending->sent || (int32_t)(now_millis - pending->expires_at_millis) < 0) continue; + +- const char *response = "Trace timed out"; ++ char response[BOT_MAX_RESPONSE_LEN + 1]; ++ uint8_t hop_count = pending->path_len / botTraceHashSize(pending->flags); ++ snprintf(response, sizeof(response), "Trace timed out: no reply on %u-hop route", (unsigned)hop_count); + char final_response[BOT_MAX_RESPONSE_LEN + 1]; + size_t final_response_len = 0; + if (botFormatResponseForChannelKind(pending->channel_kind, response, botBoundedStrLen(response, BOT_MAX_RESPONSE_LEN + 1), +@@ -1491,19 +1565,43 @@ void MyMesh::recordBotObservation(const BotMessage &message, const ContactInfo * + + char response[BOT_MAX_RESPONSE_LEN + 1]; + BotCommandResult result; +- if (command.id == BOT_COMMAND_TRACE && handleBotTraceCommand(message, direct_recipient, channel_idx, command)) return; ++ bool result_ready = false; ++ if (command.id == BOT_COMMAND_TRACE || command.id == BOT_COMMAND_TRACER) { ++ if (handleBotTraceCommand(message, direct_recipient, channel_idx, command)) { ++ return; ++ } ++ if (command.args_len > 0) { ++ result = botWriteFormatted(response, sizeof(response), "Usage: %s [path]", ++ command.id == BOT_COMMAND_TRACER ? "tracer" : "trace"); ++ result_ready = true; ++ } ++ } + + BotCommandContext context; + buildBotCommandContext(context, command.id); +- if (command.id == BOT_COMMAND_PATH || command.id == BOT_COMMAND_TRACER) { ++ if (command.id == BOT_COMMAND_PATH || command.id == BOT_COMMAND_TRACE || command.id == BOT_COMMAND_TRACER) { + context.path_len = message.path_len; +- context.path_hash_size = message.path_hash_size; ++ context.path_hash_size = command.args_len > 0 ? botConfiguredTraceHashSize(_prefs.path_hash_mode) : message.path_hash_size; + context.path_hash_count = message.path_hash_count; + context.path_snr_quarters = message.packet_snr_quarters; ++ if (message.channel_kind != BOT_CHANNEL_DM && message.sender_name[0]) { ++ StrHelper::strzcpy(context.response_target, message.sender_name, sizeof(context.response_target)); ++ } + context.path = message.path; + } +- result = command.id == BOT_COMMAND_PREFIX ? executeBotPrefixCommand(command, response, sizeof(response)) +- : BotCommands::executeCommand(command, context, response, sizeof(response)); ++ if (!result_ready) { ++ if (command.id == BOT_COMMAND_TEST) { ++ size_t written = 0; ++ BotWriteResult write_result = FirmwareBot::writeAckResponse(message, command, response, sizeof(response), &written); ++ result.code = write_result == BOT_WRITE_NO_SPACE ? BOT_COMMAND_RESULT_NO_SPACE ++ : (write_result == BOT_WRITE_TRUNCATED ? BOT_COMMAND_RESULT_TRUNCATED ++ : BOT_COMMAND_RESULT_OK); ++ result.text_len = written; ++ } else { ++ result = command.id == BOT_COMMAND_PREFIX ? executeBotPrefixCommand(command, response, sizeof(response)) ++ : BotCommands::executeCommand(command, context, response, sizeof(response)); ++ } ++ } + if (result.code == BOT_COMMAND_RESULT_NOT_HANDLED || result.code == BOT_COMMAND_RESULT_NO_SPACE || result.text_len == 0) { + bot_stats.parse_errors++; + return; +diff --git a/platformio.ini b/platformio.ini +index dfed7dbd..e3e4b30f 100644 +--- a/platformio.ini ++++ b/platformio.ini +@@ -57,6 +57,7 @@ build_src_filter = + [cmesh_bot_production] + build_flags = + -D CMESH_BOT_ENABLED=1 ++ -D BOT_LOCAL_TIME_OFFSET_SECONDS=-21600 + -UENABLE_PRIVATE_KEY_IMPORT + -UENABLE_PRIVATE_KEY_EXPORT + -D ENABLE_PRIVATE_KEY_IMPORT=0 diff --git a/patches/meshcore/0005-Add-hop-aware-bot-coordination-utility-commands-and-.patch b/patches/meshcore/0005-Add-hop-aware-bot-coordination-utility-commands-and-.patch new file mode 100644 index 0000000..4856ee7 --- /dev/null +++ b/patches/meshcore/0005-Add-hop-aware-bot-coordination-utility-commands-and-.patch @@ -0,0 +1,1277 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: cj-vana +Date: Sat, 16 May 2026 12:38:13 -0600 +Subject: [PATCH 5/5] Add hop-aware bot coordination, utility commands, and + bounded delays + +Adds new utility commands (TIME, LORA, ID, NEIGHBORS), neighbor tracking, +per-hop response delay coordination, received_at_timestamp tracking, and +personalized ack targets. + +Tunes the response coordinator so multi-hop senders actually get responses: + +- Hop step default: 5000ms -> 1500ms per hop +- Adds BOT_HOP_BIAS_MAX_MILLIS cap of 8000ms on total hop delay +- Pending TTL: 15000ms -> 45000ms +- Bumps BOT_PREFS_VERSION 2 -> 3 to force defaults on existing bots that + already saved the broken 5000ms hop_step_ms value + +With the old defaults a 3-hop channel sender would queue a response at +~16400ms (1200 base + 200 channel + 15000 hop bias) which exceeded the +15000ms TTL, so poll() always returned READY_EXPIRED and the bot stayed +silent for multi-hop senders. New worst case (10+ hops, full jitter, +queue depth 10) is ~13600ms, well under the new 45000ms TTL. +--- + .../companion_radio/BotCommandRegistry.cpp | 10 + + examples/companion_radio/BotCommands.cpp | 129 ++++-- + examples/companion_radio/BotCommands.h | 4 + + examples/companion_radio/BotPrefs.cpp | 5 + + examples/companion_radio/BotTypes.h | 36 +- + .../companion_radio/EmergencyForwarder.cpp | 2 +- + examples/companion_radio/FirmwareBot.cpp | 68 ++-- + examples/companion_radio/MyMesh.cpp | 378 +++++++++++++++--- + examples/companion_radio/MyMesh.h | 11 +- + .../companion_radio/ResponseCoordinator.cpp | 45 ++- + .../companion_radio/ResponseCoordinator.h | 9 + + src/Mesh.cpp | 16 +- + 12 files changed, 569 insertions(+), 144 deletions(-) + +diff --git a/examples/companion_radio/BotCommandRegistry.cpp b/examples/companion_radio/BotCommandRegistry.cpp +index 49888a7a..c429259c 100644 +--- a/examples/companion_radio/BotCommandRegistry.cpp ++++ b/examples/companion_radio/BotCommandRegistry.cpp +@@ -13,6 +13,7 @@ const char* const kMagic8Aliases[] = { "8ball", "eightball" }; + const char* const kChannelsAliases[] = { "channel" }; + const char* const kPathAliases[] = { "p", "decode", "route" }; + const char* const kPrefixAliases[] = { "lookup" }; ++const char* const kNeighborsAliases[] = { "near" }; + + size_t boundedStrLen(const char* value, size_t max_len) { + size_t len = 0; +@@ -64,6 +65,15 @@ const BotCommandMetadata kCommands[] = { + BOT_COMMAND_CONTEXT_TRACE, "Run link trace", "tracer [path]", "Run link trace using reciprocal local MeshCore state." }, + { BOT_COMMAND_PREFIX, "prefix", kPrefixAliases, 1, BOT_COMMAND_MASK_PREFIX, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_LOCAL_CONTACT, "Look up local prefix", "prefix ", "Look up a local contact by public-key prefix using local firmware contacts only." }, ++ { BOT_COMMAND_TIME, "time", NULL, 0, BOT_COMMAND_MASK_TIME, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ BOT_COMMAND_CONTEXT_DIAGNOSTIC, "Show bot time and uptime", "time", "Show local bot wall-clock time and uptime." }, ++ { BOT_COMMAND_LORA, "lora", NULL, 0, BOT_COMMAND_MASK_LORA, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ BOT_COMMAND_CONTEXT_DIAGNOSTIC, "Show LoRa radio settings", "lora", "Show local LoRa frequency, spreading factor, bandwidth, coding rate, and TX power." }, ++ { BOT_COMMAND_ID, "id", NULL, 0, BOT_COMMAND_MASK_ID, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ BOT_COMMAND_CONTEXT_DIAGNOSTIC, "Show bot public-key prefix", "id", "Show local bot public-key prefix and node name." }, ++ { BOT_COMMAND_NEIGHBORS, "neighbors", kNeighborsAliases, 1, BOT_COMMAND_MASK_NEIGHBORS, BOT_COMMAND_VISIBILITY_DISCOVERABLE, ++ BOT_COMMAND_CONTEXT_DIAGNOSTIC, "Show recent direct neighbors", "neighbors", ++ "Show nodes heard directly within the last hour with RSSI and SNR." }, + { BOT_COMMAND_UNKNOWN, "unknown", NULL, 0, 0, BOT_COMMAND_VISIBILITY_INTERNAL, + BOT_COMMAND_CONTEXT_INTERNAL, "Unknown command", "unknown", "Internal unknown-command handler." } + }; +diff --git a/examples/companion_radio/BotCommands.cpp b/examples/companion_radio/BotCommands.cpp +index f3c9bc0e..44357a99 100644 +--- a/examples/companion_radio/BotCommands.cpp ++++ b/examples/companion_radio/BotCommands.cpp +@@ -326,15 +326,15 @@ void appendCommandList(char* output, size_t output_len, size_t* pos, const BotCo + + BotCommandResult executeCmd(char* output, size_t output_len) { + static const BotCommandId ids[] = { +- BOT_COMMAND_TEST, BOT_COMMAND_PING, BOT_COMMAND_HELP, BOT_COMMAND_HELLO, BOT_COMMAND_CMD, BOT_COMMAND_ABOUT, ++ BOT_COMMAND_TEST, BOT_COMMAND_PING, BOT_COMMAND_HELP, BOT_COMMAND_HELLO, BOT_COMMAND_ABOUT, + BOT_COMMAND_ROLL, BOT_COMMAND_DICE, BOT_COMMAND_STATUS, BOT_COMMAND_CHANNELS, BOT_COMMAND_VERSION, +- BOT_COMMAND_STATS, BOT_COMMAND_MAGIC8, BOT_COMMAND_PATH, BOT_COMMAND_TRACE, BOT_COMMAND_TRACER, +- BOT_COMMAND_PREFIX ++ BOT_COMMAND_STATS, BOT_COMMAND_PATH, BOT_COMMAND_TRACE, BOT_COMMAND_TRACER, BOT_COMMAND_PREFIX, ++ BOT_COMMAND_TIME, BOT_COMMAND_LORA, BOT_COMMAND_ID, BOT_COMMAND_NEIGHBORS + }; + if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); + output[0] = 0; + size_t pos = 0; +- appendText(output, output_len, &pos, "Available commands: "); ++ appendText(output, output_len, &pos, "Commands: "); + appendCommandList(output, output_len, &pos, ids, sizeof(ids) / sizeof(ids[0])); + return resultForAppend(output, output_len, pos); + } +@@ -343,7 +343,8 @@ BotCommandResult executeHelp(const BotCommand& command, char* output, size_t out + if (command.args_len == 0) { + static const BotCommandId ids[] = { + BOT_COMMAND_HELP, BOT_COMMAND_CMD, BOT_COMMAND_PING, BOT_COMMAND_TEST, BOT_COMMAND_HELLO, BOT_COMMAND_ROLL, +- BOT_COMMAND_DICE, BOT_COMMAND_TRACE, BOT_COMMAND_TRACER, BOT_COMMAND_PREFIX ++ BOT_COMMAND_DICE, BOT_COMMAND_TRACE, BOT_COMMAND_TRACER, BOT_COMMAND_PREFIX, BOT_COMMAND_TIME, ++ BOT_COMMAND_LORA, BOT_COMMAND_ID, BOT_COMMAND_NEIGHBORS + }; + if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); + output[0] = 0; +@@ -380,17 +381,16 @@ BotCommandResult executePathLike(const BotCommandContext& context, char* output, + formatQuarters(context.path_snr_quarters, snr, sizeof(snr)); + const char* target = context.response_target[0] ? context.response_target : NULL; + if (context.path_hash_count == 0 || context.path_len == 0) { +- if (target) return writeFormatted(output, output_len, "%s @[%s] route: direct zero-hop, SNR %s", label, target, snr); +- return writeFormatted(output, output_len, "%s route: direct zero-hop, SNR %s", label, snr); ++ if (target) return writeFormatted(output, output_len, "%s @[%s] direct zero-hop SNR %s", label, target, snr); ++ return writeFormatted(output, output_len, "%s direct zero-hop SNR %s", label, snr); + } + if (!context.path) return writeFormatted(output, output_len, "%s unavailable", label); + +- int written = target ? snprintf(output, output_len, "%s @[%s] route: %u hop%s, %u-byte hashes, SNR %s | ", label, +- target, (unsigned)context.path_hash_count, +- context.path_hash_count == 1 ? "" : "s", (unsigned)context.path_hash_size, snr) +- : snprintf(output, output_len, "%s route: %u hop%s, %u-byte hashes, SNR %s | ", label, +- (unsigned)context.path_hash_count, context.path_hash_count == 1 ? "" : "s", +- (unsigned)context.path_hash_size, snr); ++ int written = target ++ ? snprintf(output, output_len, "%s @[%s] %uh@%uB SNR %s | ", label, target, ++ (unsigned)context.path_hash_count, (unsigned)context.path_hash_size, snr) ++ : snprintf(output, output_len, "%s %uh@%uB SNR %s | ", label, ++ (unsigned)context.path_hash_count, (unsigned)context.path_hash_size, snr); + if (written < 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); + size_t pos = (size_t)written; + appendPathHops(output, output_len, &pos, context.path, context.path_hash_size, context.path_hash_count); +@@ -402,8 +402,7 @@ BotCommandResult executePathArg(const BotCommand& command, const BotCommandConte + ParsedPathArg path; + if (!parsePathArgument(command.args, command.args_len, context.path_hash_size, &path)) return writeText(output, output_len, "Usage: path [path]"); + if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); +- int written = snprintf(output, output_len, "Path route: %u hop%s, %u-byte hashes | ", (unsigned)path.hash_count, +- path.hash_count == 1 ? "" : "s", (unsigned)path.hash_size); ++ int written = snprintf(output, output_len, "Path %uh@%uB | ", (unsigned)path.hash_count, (unsigned)path.hash_size); + if (written < 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); + size_t pos = (size_t)written; + appendPathHops(output, output_len, &pos, path.bytes, path.hash_size, path.hash_count); +@@ -422,15 +421,15 @@ BotCommandResult executeTraceLike(const BotCommand& command, const BotCommandCon + if (context.path_hash_size == 0) { + return writeFormatted(output, output_len, "%s needs a known route. Try path first.", label); + } +- if (context.path_hash_count == 0 || context.path_len == 0) return writeFormatted(output, output_len, "%s pending on direct zero-hop route", label); ++ if (context.path_hash_count == 0 || context.path_len == 0) return writeFormatted(output, output_len, "%s ready: direct zero-hop route", label); + if (!context.path) return writeFormatted(output, output_len, "%s needs a known route. Try path first.", label); +- return writeFormatted(output, output_len, "%s pending on %u-hop route", label, (unsigned)context.path_hash_count); ++ return writeFormatted(output, output_len, "%s ready: %u-hop route", label, (unsigned)context.path_hash_count); + } + ParsedPathArg path; + if (!parsePathArgument(command.args, command.args_len, context.path_hash_size, &path)) { + return writeFormatted(output, output_len, "Usage: %s [path]", usage); + } +- return writeFormatted(output, output_len, "%s pending on %u-hop route", label, (unsigned)path.hash_count); ++ return writeFormatted(output, output_len, "%s ready: %u-hop route", label, (unsigned)path.hash_count); + } + + BotCommandResult executeRoll(const BotCommand& command, const BotCommandContext& context, char* output, size_t output_len) { +@@ -515,21 +514,77 @@ void formatSecondsHms(uint32_t timestamp, char* output, size_t output_len) { + (unsigned long)((seconds / 60UL) % 60UL), (unsigned long)(seconds % 60UL)); + } + ++void formatUptime(uint32_t seconds, char* output, size_t output_len) { ++ if (!output || output_len == 0) return; ++ uint32_t days = seconds / 86400UL; ++ uint32_t hours = (seconds / 3600UL) % 24UL; ++ uint32_t mins = (seconds / 60UL) % 60UL; ++ snprintf(output, output_len, "%lud %luh %lum", (unsigned long)days, (unsigned long)hours, (unsigned long)mins); ++} ++ ++uint8_t batteryPercentFromMillivolts(uint16_t mv) { ++ if (mv == 0) return 0; ++ const uint16_t kEmpty = 3300; ++ const uint16_t kFull = 4200; ++ if (mv <= kEmpty) return 0; ++ if (mv >= kFull) return 100; ++ return (uint8_t)(((uint32_t)(mv - kEmpty) * 100UL) / (uint32_t)(kFull - kEmpty)); ++} ++ + BotCommandResult executeTest(const BotCommand& command, const BotCommandContext& context, char* output, size_t output_len) { + const char* name = context.node_name[0] ? context.node_name : "local"; + char received_at[9]; + formatSecondsHms(context.uptime_seconds, received_at, sizeof(received_at)); + if (command.args_len == 0) { +- return writeFormatted(output, output_len, "ack @[%s] | local | Received at: %s", name, received_at); ++ return writeFormatted(output, output_len, "ack @[%s] local | recv %s", name, received_at); + } +- return writeFormatted(output, output_len, "ack @[%s] %s | local | Received at: %s", name, command.args, +- received_at); ++ return writeFormatted(output, output_len, "ack @[%s] local | recv %s | %s", name, received_at, command.args); + } + + } + + namespace BotCommands { + ++size_t formatTraceResult(char* output, size_t output_len, const char* target, uint32_t tag, uint8_t hash_size, ++ const uint8_t* path_snrs, const uint8_t* path_hashes, uint8_t hop_count, ++ int8_t tail_snr_quarters) { ++ if (!output || output_len == 0) return 0; ++ char tail_snr[8]; ++ formatQuarters(tail_snr_quarters, tail_snr, sizeof(tail_snr)); ++ bool has_target = target && target[0]; ++ int written; ++ if (hop_count == 0 || hash_size == 0) { ++ written = has_target ++ ? snprintf(output, output_len, "Trace @[%s] %08lx direct zero-hop tail %s", target, ++ (unsigned long)tag, tail_snr) ++ : snprintf(output, output_len, "Trace %08lx direct zero-hop tail %s", (unsigned long)tag, tail_snr); ++ if (written < 0) { ++ output[0] = 0; ++ return 0; ++ } ++ return boundedStrLen(output, output_len); ++ } ++ written = has_target ++ ? snprintf(output, output_len, "Trace @[%s] %08lx %uh tail %s | ", target, (unsigned long)tag, ++ (unsigned)hop_count, tail_snr) ++ : snprintf(output, output_len, "Trace %08lx %uh tail %s | ", (unsigned long)tag, ++ (unsigned)hop_count, tail_snr); ++ if (written < 0) { ++ output[0] = 0; ++ return 0; ++ } ++ size_t pos = (size_t)written; ++ for (uint8_t hop = 0; hop < hop_count; hop++) { ++ if (hop != 0) appendText(output, output_len, &pos, " -> "); ++ appendPathHex(output, output_len, &pos, &path_hashes[(size_t)hop * hash_size], hash_size); ++ appendText(output, output_len, &pos, "@"); ++ char hop_snr[8]; ++ formatQuarters(path_snrs ? (int8_t)path_snrs[hop] : 0, hop_snr, sizeof(hop_snr)); ++ appendText(output, output_len, &pos, hop_snr); ++ } ++ return boundedStrLen(output, output_len); ++} ++ + BotCommandResult executeCommand(const BotCommand& command, const BotCommandContext& context, char* output, + size_t output_len) { + switch (command.id) { +@@ -541,22 +596,37 @@ BotCommandResult executeCommand(const BotCommand& command, const BotCommandConte + return writeText(output, output_len, "Pong!"); + case BOT_COMMAND_TEST: + return executeTest(command, context, output, output_len); +- case BOT_COMMAND_HELLO: +- return writeFormatted(output, output_len, "Hello from %s", context.node_name[0] ? context.node_name : "MeshCore bot"); ++ case BOT_COMMAND_HELLO: { ++ const char* node = context.node_name[0] ? context.node_name : "MeshCore bot"; ++ const char* target = context.response_target[0] ? context.response_target : NULL; ++ if (target) return writeFormatted(output, output_len, "Hello @[%s], from %s", target, node); ++ return writeFormatted(output, output_len, "Hello from %s", node); ++ } + case BOT_COMMAND_ABOUT: + return writeText(output, output_len, "Colorado Mesh firmware bot: local commands only, no internet required."); + case BOT_COMMAND_ROLL: + return executeRoll(command, context, output, output_len); + case BOT_COMMAND_DICE: + return executeDice(command, context, output, output_len); +- case BOT_COMMAND_STATUS: +- return writeFormatted(output, output_len, "%s up %lus batt %umV storage %lu/%luKB seen %lu sent %lu fail %lu", +- context.node_name[0] ? context.node_name : "bot", (unsigned long)context.uptime_seconds, +- (unsigned)context.battery_millivolts, (unsigned long)context.storage_used_kb, ++ case BOT_COMMAND_STATUS: { ++ char up_str[20]; ++ formatUptime(context.uptime_seconds, up_str, sizeof(up_str)); ++ const char* name = context.node_name[0] ? context.node_name : "bot"; ++ if (context.battery_millivolts > 0) { ++ uint8_t pct = batteryPercentFromMillivolts(context.battery_millivolts); ++ return writeFormatted(output, output_len, "%s | up %s | batt %umV %u%% | storage %lu/%luKB | seen %lu sent %lu fail %lu", ++ name, up_str, (unsigned)context.battery_millivolts, (unsigned)pct, ++ (unsigned long)context.storage_used_kb, (unsigned long)context.storage_total_kb, ++ (unsigned long)context.observed_messages, (unsigned long)context.sent_messages, ++ (unsigned long)context.send_failures); ++ } ++ return writeFormatted(output, output_len, "%s | up %s | storage %lu/%luKB | seen %lu sent %lu fail %lu", ++ name, up_str, (unsigned long)context.storage_used_kb, + (unsigned long)context.storage_total_kb, (unsigned long)context.observed_messages, + (unsigned long)context.sent_messages, (unsigned long)context.send_failures); ++ } + case BOT_COMMAND_CHANNELS: +- return writeFormatted(output, output_len, "Channels: %s %s emergency=%s public=%s (%u configured)", ++ return writeFormatted(output, output_len, "Channels: bot %s | testing %s | emergency %s | public %s (%u total)", + context.bot_channel[0] ? context.bot_channel : "#bot", + context.testing_channel[0] ? context.testing_channel : "#testing", + context.emergency_channel[0] ? context.emergency_channel : "#emergency", +@@ -565,10 +635,9 @@ BotCommandResult executeCommand(const BotCommand& command, const BotCommandConte + return writeFormatted(output, output_len, "Firmware %s built %s", context.firmware_version[0] ? context.firmware_version : "unknown", + context.firmware_build_date[0] ? context.firmware_build_date : "unknown"); + case BOT_COMMAND_STATS: +- return writeFormatted(output, output_len, "Bot seen %lu ok %lu sent %lu fail %lu sup %lu pend %lu rf rx/tx %lu/%lu err %lu q %u", ++ return writeFormatted(output, output_len, "Bot: %lu seen, %lu ok, %lu sent, %lu fail | RF: %lu rx, %lu tx, %lu err, q %u", + (unsigned long)context.observed_messages, (unsigned long)context.eligible_messages, + (unsigned long)context.sent_messages, (unsigned long)context.send_failures, +- (unsigned long)context.suppressed_responses, (unsigned long)context.pending_responses, + (unsigned long)context.packets_recv, (unsigned long)context.packets_sent, + (unsigned long)context.packets_recv_errors, (unsigned)context.queue_depth); + case BOT_COMMAND_MAGIC8: +diff --git a/examples/companion_radio/BotCommands.h b/examples/companion_radio/BotCommands.h +index b0b4cddb..adc3ef73 100644 +--- a/examples/companion_radio/BotCommands.h ++++ b/examples/companion_radio/BotCommands.h +@@ -7,4 +7,8 @@ namespace BotCommands { + BotCommandResult executeCommand(const BotCommand& command, const BotCommandContext& context, char* output, + size_t output_len); + ++size_t formatTraceResult(char* output, size_t output_len, const char* target, uint32_t tag, uint8_t hash_size, ++ const uint8_t* path_snrs, const uint8_t* path_hashes, uint8_t hop_count, ++ int8_t tail_snr_quarters); ++ + } +diff --git a/examples/companion_radio/BotPrefs.cpp b/examples/companion_radio/BotPrefs.cpp +index 799b6458..6c7d553f 100644 +--- a/examples/companion_radio/BotPrefs.cpp ++++ b/examples/companion_radio/BotPrefs.cpp +@@ -117,6 +117,7 @@ void defaults(BotPrefs& prefs) { + prefs.enabled = true; + prefs.normal_delay_ms = BOT_RESPONSE_DELAY_BASE_MILLIS; + prefs.normal_jitter_ms = BOT_RESPONSE_DELAY_JITTER_MILLIS; ++ prefs.hop_step_ms = BOT_HOP_STEP_MILLIS_DEFAULT; + prefs.local_advert_interval_ms = BOT_PREFS_DEFAULT_LOCAL_ADVERT_MILLIS; + prefs.flood_advert_interval_ms = BOT_PREFS_DEFAULT_FLOOD_ADVERT_MILLIS; + prefs.command_mask = BOT_COMMAND_MASK_ALL; +@@ -130,6 +131,8 @@ void defaults(BotPrefs& prefs) { + void validate(BotPrefs& prefs) { + prefs.normal_delay_ms = prefs.normal_delay_ms > BOT_PREFS_MAX_DELAY_MILLIS ? BOT_PREFS_MAX_DELAY_MILLIS : prefs.normal_delay_ms; + prefs.normal_jitter_ms = prefs.normal_jitter_ms > BOT_PREFS_MAX_DELAY_MILLIS ? BOT_PREFS_MAX_DELAY_MILLIS : prefs.normal_jitter_ms; ++ if (prefs.hop_step_ms == 0) prefs.hop_step_ms = BOT_HOP_STEP_MILLIS_DEFAULT; ++ if (prefs.hop_step_ms > BOT_HOP_STEP_MILLIS_MAX) prefs.hop_step_ms = BOT_HOP_STEP_MILLIS_MAX; + if (prefs.local_advert_interval_ms > BOT_PREFS_MAX_ADVERT_MILLIS) prefs.local_advert_interval_ms = BOT_PREFS_MAX_ADVERT_MILLIS; + if (prefs.flood_advert_interval_ms > BOT_PREFS_MAX_ADVERT_MILLIS) prefs.flood_advert_interval_ms = BOT_PREFS_MAX_ADVERT_MILLIS; + prefs.command_mask &= BOT_COMMAND_MASK_ALL; +@@ -187,6 +190,7 @@ bool serialize(const BotPrefs& prefs, uint8_t* output, size_t output_len) { + put8(output, pos, clean.enabled ? 1 : 0); + put16(output, pos, clean.normal_delay_ms); + put16(output, pos, clean.normal_jitter_ms); ++ put16(output, pos, clean.hop_step_ms); + put32(output, pos, clean.local_advert_interval_ms); + put32(output, pos, clean.flood_advert_interval_ms); + put32(output, pos, clean.command_mask); +@@ -237,6 +241,7 @@ bool deserialize(const uint8_t* data, size_t data_len, BotPrefs& prefs) { + loaded.enabled = get8(data, pos) != 0; + loaded.normal_delay_ms = get16(data, pos); + loaded.normal_jitter_ms = get16(data, pos); ++ loaded.hop_step_ms = get16(data, pos); + loaded.local_advert_interval_ms = get32(data, pos); + loaded.flood_advert_interval_ms = get32(data, pos); + loaded.command_mask = get32(data, pos); +diff --git a/examples/companion_radio/BotTypes.h b/examples/companion_radio/BotTypes.h +index f93cae69..4fdd4bdd 100644 +--- a/examples/companion_radio/BotTypes.h ++++ b/examples/companion_radio/BotTypes.h +@@ -28,22 +28,27 @@ + #define BOT_COORDINATOR_PENDING_SLOTS 8 + #define BOT_COORDINATOR_RECENT_SLOTS 16 + #define BOT_KNOWN_BOT_SLOTS 8 ++#define BOT_NEIGHBOR_SLOTS 16 ++#define BOT_NEIGHBOR_RECENT_MILLIS (60UL * 60UL * 1000UL) + #define BOT_RESPONSE_DELAY_BASE_MILLIS 1200UL + #define BOT_RESPONSE_DELAY_JITTER_MILLIS 1800UL +-#define BOT_RESPONSE_PENDING_TTL_MILLIS 15000UL ++#define BOT_RESPONSE_PENDING_TTL_MILLIS 45000UL + #define BOT_RESPONSE_RECENT_TTL_MILLIS 30000UL + #define BOT_KNOWN_BOT_FLAG_SUPPRESS_NORMAL 0x01 + #define BOT_SENDER_KEY_PREFIX_LEN 6 + #define BOT_MIN_AUTH_SENDER_KEY_PREFIX_LEN 4 + #define BOT_KNOWN_BOT_LABEL_LEN 12 + #define BOT_PREFS_MAGIC 0x31504642UL +-#define BOT_PREFS_VERSION 1 ++#define BOT_PREFS_VERSION 3 + #define BOT_PREFS_DEFAULT_LOCAL_ADVERT_MILLIS (24UL * 60UL * 60UL * 1000UL) + #define BOT_PREFS_DEFAULT_FLOOD_ADVERT_MILLIS (24UL * 60UL * 60UL * 1000UL) + #define BOT_PREFS_INITIAL_LOCAL_ADVERT_MILLIS 60000UL + #define BOT_PREFS_MAX_DELAY_MILLIS 60000U + #define BOT_PREFS_MAX_ADVERT_MILLIS (7UL * 24UL * 60UL * 60UL * 1000UL) +-#define BOT_PREFS_SERIALIZED_SIZE 294 ++#define BOT_PREFS_SERIALIZED_SIZE 296 ++#define BOT_HOP_STEP_MILLIS_DEFAULT 1500U ++#define BOT_HOP_STEP_MILLIS_MAX 30000U ++#define BOT_HOP_BIAS_MAX_MILLIS 8000UL + + enum BotChannelKind : uint8_t { + BOT_CHANNEL_DM = 0, +@@ -79,8 +84,12 @@ enum BotCommandId : uint8_t { + BOT_COMMAND_TRACE = 15, + BOT_COMMAND_TRACER = 16, + BOT_COMMAND_PREFIX = 17, +- BOT_COMMAND_UNSUPPORTED = 18, +- BOT_COMMAND_UNKNOWN = 19 ++ BOT_COMMAND_TIME = 18, ++ BOT_COMMAND_LORA = 19, ++ BOT_COMMAND_ID = 20, ++ BOT_COMMAND_NEIGHBORS = 21, ++ BOT_COMMAND_UNSUPPORTED = 22, ++ BOT_COMMAND_UNKNOWN = 23 + }; + + enum BotCommandVisibility : uint8_t { +@@ -128,12 +137,17 @@ struct BotCommandMetadata { + #define BOT_COMMAND_MASK_TRACE (1UL << BOT_COMMAND_TRACE) + #define BOT_COMMAND_MASK_TRACER (1UL << BOT_COMMAND_TRACER) + #define BOT_COMMAND_MASK_PREFIX (1UL << BOT_COMMAND_PREFIX) ++#define BOT_COMMAND_MASK_TIME (1UL << BOT_COMMAND_TIME) ++#define BOT_COMMAND_MASK_LORA (1UL << BOT_COMMAND_LORA) ++#define BOT_COMMAND_MASK_ID (1UL << BOT_COMMAND_ID) ++#define BOT_COMMAND_MASK_NEIGHBORS (1UL << BOT_COMMAND_NEIGHBORS) + #define BOT_COMMAND_MASK_ALL (BOT_COMMAND_MASK_HELP | BOT_COMMAND_MASK_CMD | BOT_COMMAND_MASK_PING | \ + BOT_COMMAND_MASK_TEST | BOT_COMMAND_MASK_HELLO | BOT_COMMAND_MASK_ABOUT | \ + BOT_COMMAND_MASK_ROLL | BOT_COMMAND_MASK_DICE | BOT_COMMAND_MASK_STATUS | \ + BOT_COMMAND_MASK_CHANNELS | BOT_COMMAND_MASK_VERSION | BOT_COMMAND_MASK_STATS | \ + BOT_COMMAND_MASK_MAGIC8 | BOT_COMMAND_MASK_PATH | BOT_COMMAND_MASK_TRACE | \ +- BOT_COMMAND_MASK_TRACER | BOT_COMMAND_MASK_PREFIX) ++ BOT_COMMAND_MASK_TRACER | BOT_COMMAND_MASK_PREFIX | BOT_COMMAND_MASK_TIME | \ ++ BOT_COMMAND_MASK_LORA | BOT_COMMAND_MASK_ID | BOT_COMMAND_MASK_NEIGHBORS) + + enum BotCommandResultCode : uint8_t { + BOT_COMMAND_RESULT_NOT_HANDLED = 0, +@@ -260,6 +274,14 @@ struct BotKnownBotEntry { + char label[BOT_KNOWN_BOT_LABEL_LEN]; + }; + ++struct BotNeighbor { ++ bool active; ++ uint8_t pub_key_prefix[BOT_SENDER_KEY_PREFIX_LEN]; ++ uint32_t last_heard_millis; ++ int16_t rssi_dbm; ++ int8_t snr_quarters; ++}; ++ + struct BotCoordinatorPending { + bool active; + bool suppressed; +@@ -292,6 +314,7 @@ struct BotPrefs { + bool enabled; + uint16_t normal_delay_ms; + uint16_t normal_jitter_ms; ++ uint16_t hop_step_ms; + uint32_t local_advert_interval_ms; + uint32_t flood_advert_interval_ms; + uint32_t command_mask; +@@ -329,6 +352,7 @@ static_assert(sizeof(BotCommandContext) <= 312, "BotCommandContext RAM budget ex + static_assert(sizeof(BotCommandResult) <= 16, "BotCommandResult RAM budget exceeded"); + static_assert(sizeof(BotCommandCooldown) <= 8, "BotCommandCooldown RAM budget exceeded"); + static_assert(sizeof(BotKnownBotEntry) <= 24, "BotKnownBotEntry RAM budget exceeded"); ++static_assert(sizeof(BotNeighbor) <= 24, "BotNeighbor RAM budget exceeded"); + static_assert(sizeof(BotCoordinatorPending) <= 32, "BotCoordinatorPending RAM budget exceeded"); + static_assert(sizeof(BotCoordinatorRecent) <= 24, "BotCoordinatorRecent RAM budget exceeded"); + static_assert(sizeof(BotCoordinatorReady) <= 24, "BotCoordinatorReady RAM budget exceeded"); +diff --git a/examples/companion_radio/EmergencyForwarder.cpp b/examples/companion_radio/EmergencyForwarder.cpp +index b228a8fa..b3f2ff46 100644 +--- a/examples/companion_radio/EmergencyForwarder.cpp ++++ b/examples/companion_radio/EmergencyForwarder.cpp +@@ -73,7 +73,7 @@ bool format(const BotMessage& message, BotEmergencyForward& forward) { + + char header[BOT_MAX_GROUP_RESPONSE_LEN + 1]; + const char* sender = message.sender_name[0] ? message.sender_name : "unknown"; +- int header_len_int = snprintf(header, sizeof(header), BOT_EMERGENCY_PREFIX "%s: ", sender); ++ int header_len_int = snprintf(header, sizeof(header), BOT_EMERGENCY_PREFIX "@[%s]: ", sender); + if (header_len_int < 0) return false; + size_t header_len = (size_t)header_len_int; + if (header_len >= sizeof(header)) header_len = sizeof(header) - 1; +diff --git a/examples/companion_radio/FirmwareBot.cpp b/examples/companion_radio/FirmwareBot.cpp +index de6dd92f..6aac8a1b 100644 +--- a/examples/companion_radio/FirmwareBot.cpp ++++ b/examples/companion_radio/FirmwareBot.cpp +@@ -299,15 +299,30 @@ BotWriteResult writeResponseForChannel(BotChannelKind channel_kind, bool allow_p + return result; + } + ++namespace { ++ ++void appendBlock(char* output, size_t output_len, size_t* pos, bool* truncated, const char* block, size_t block_len) { ++ if (!output || output_len == 0 || !pos || !truncated || !block) return; ++ size_t available = *pos + 1 < output_len ? output_len - 1 - *pos : 0; ++ size_t copy_len = block_len < available ? block_len : available; ++ if (copy_len > 0) memcpy(&output[*pos], block, copy_len); ++ *pos += block_len; ++ if (block_len > available) *truncated = true; ++ output[*pos < output_len ? *pos : output_len - 1] = 0; ++} ++ ++} ++ + BotWriteResult writeAckResponse(const BotMessage& message, const BotCommand& command, char* output, size_t output_len, + size_t* written) { + if (written) *written = 0; + if (!output || output_len == 0) return BOT_WRITE_NO_SPACE; + + const char* sender = message.sender_name[0] ? message.sender_name : "unknown"; +- const char* connection = message.channel_kind == BOT_CHANNEL_DM ? "direct" : "channel"; +- int n = command.args_len > 0 ? snprintf(output, output_len, "ack @[%s] %s | %s", sender, command.args, connection) +- : snprintf(output, output_len, "ack @[%s] | %s", sender, connection); ++ const char* connection = message.channel_kind == BOT_CHANNEL_DM ++ ? "direct" ++ : (message.channel_name[0] ? message.channel_name : "channel"); ++ int n = snprintf(output, output_len, "ack @[%s] %s", sender, connection); + if (n < 0) { + output[0] = 0; + return BOT_WRITE_NO_SPACE; +@@ -316,37 +331,32 @@ BotWriteResult writeAckResponse(const BotMessage& message, const BotCommand& com + bool truncated = pos >= output_len; + if (truncated) pos = output_len - 1; + +- if (message.path_hash_count > 0 && message.path_hash_size > 0) { +- char path_info[36]; +- int value = message.packet_snr_quarters; +- const char* sign = value < 0 ? "-" : ""; +- if (value < 0) value = -value; +- int path_n = snprintf(path_info, sizeof(path_info), " path %uh x %uB snr %s%d.%02d", +- (unsigned)message.path_hash_count, (unsigned)message.path_hash_size, sign, value / 4, +- (value % 4) * 25); +- if (path_n > 0) { +- size_t path_len = (size_t)path_n; +- size_t available = pos + 1 < output_len ? output_len - 1 - pos : 0; +- size_t copy_len = path_len < available ? path_len : available; +- if (copy_len > 0) memcpy(&output[pos], path_info, copy_len); +- pos += path_len; +- if (path_len > available) truncated = true; +- output[pos < output_len ? pos : output_len - 1] = 0; +- } ++ int value = message.packet_snr_quarters; ++ const char* sign = value < 0 ? "-" : ""; ++ if (value < 0) value = -value; ++ char path_block[40]; ++ int path_n; ++ if (message.path_hash_count == 0 || message.path_hash_size == 0) { ++ path_n = snprintf(path_block, sizeof(path_block), " | 0h SNR %s%d.%02d", sign, value / 4, (value % 4) * 25); ++ } else { ++ path_n = snprintf(path_block, sizeof(path_block), " | %uh@%uB SNR %s%d.%02d", ++ (unsigned)message.path_hash_count, (unsigned)message.path_hash_size, sign, value / 4, ++ (value % 4) * 25); + } ++ if (path_n > 0) appendBlock(output, output_len, &pos, &truncated, path_block, (size_t)path_n); + + char received_at[9]; + formatTimestampHms(message.received_at_timestamp, received_at, sizeof(received_at)); +- char received[32]; +- int received_n = snprintf(received, sizeof(received), " | Received at: %s", received_at); ++ char received_block[32]; ++ int received_n = snprintf(received_block, sizeof(received_block), " | recv %s", received_at); + if (received_n < 0) return BOT_WRITE_NO_SPACE; +- size_t received_len = (size_t)received_n; +- size_t available = pos + 1 < output_len ? output_len - 1 - pos : 0; +- size_t copy_len = received_len < available ? received_len : available; +- if (copy_len > 0) memcpy(&output[pos], received, copy_len); +- pos += received_len; +- if (received_len > available) truncated = true; +- output[pos < output_len ? pos : output_len - 1] = 0; ++ appendBlock(output, output_len, &pos, &truncated, received_block, (size_t)received_n); ++ ++ if (command.args_len > 0) { ++ char args_block[BOT_MAX_COMMAND_ARGS_LEN + 8]; ++ int args_n = snprintf(args_block, sizeof(args_block), " | %s", command.args); ++ if (args_n > 0) appendBlock(output, output_len, &pos, &truncated, args_block, (size_t)args_n); ++ } + + size_t actual = boundedStrLen(output, output_len); + if (written) *written = actual; +diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp +index 7b87f14f..34517c44 100644 +--- a/examples/companion_radio/MyMesh.cpp ++++ b/examples/companion_radio/MyMesh.cpp +@@ -394,31 +394,6 @@ static size_t botFormatTraceSent(char *output, size_t output_len, uint8_t hop_co + return botBoundedStrLen(output, output_len); + } + +-static size_t botFormatTraceResult(char *output, size_t output_len, uint32_t tag, uint8_t flags, +- const uint8_t *path_hashes, uint8_t path_len, int8_t snr_quarters) { +- if (!output || output_len == 0) return 0; +- uint8_t hash_size = botTraceHashSize(flags); +- uint8_t hop_count = path_len / hash_size; +- char snr[8]; +- botFormatQuarters(snr_quarters, snr, sizeof(snr)); +- if (hop_count == 0) { +- int direct_written = snprintf(output, output_len, "Trace %08lx: direct zero-hop, SNR %s", (unsigned long)tag, snr); +- if (direct_written < 0) { +- output[0] = 0; +- return 0; +- } +- return botBoundedStrLen(output, output_len); +- } +- int written = snprintf(output, output_len, "Trace %08lx: %u hop%s, SNR %s | ", (unsigned long)tag, +- (unsigned)hop_count, hop_count == 1 ? "" : "s", snr); +- if (written < 0) { +- output[0] = 0; +- return 0; +- } +- size_t pos = (size_t)written; +- botAppendPathHops(output, output_len, &pos, path_hashes, hash_size, hop_count); +- return botBoundedStrLen(output, output_len); +-} + #endif + + // these are _pushed_ to client app at any time +@@ -663,6 +638,13 @@ void MyMesh::onContactsFull() { + void MyMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path_len, const uint8_t* path) { + #if CMESH_BOT_ENABLED + autoLearnKnownBotAdvert(contact); ++ if ((path_len & 63) == 0) { ++ float rssi = radio_driver.getLastRSSI(); ++ if (rssi > 32767.0f) rssi = 32767.0f; ++ if (rssi < -32768.0f) rssi = -32768.0f; ++ int8_t snr_q = (int8_t)(radio_driver.getLastSNR() * 4); ++ recordBotNeighbor(contact.id.pub_key, (int16_t)rssi, snr_q); ++ } + #endif + + if (_serial->isConnected()) { +@@ -833,7 +815,7 @@ void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t + markConnectionActive(from); // in case this is from a server, and we have a connection + queueMessage(from, TXT_TYPE_PLAIN, pkt, sender_timestamp, NULL, 0, text); + #if CMESH_BOT_ENABLED +- observeBotDirectMessage(from, sender_timestamp, from.id.pub_key, BOT_SENDER_KEY_PREFIX_LEN, text); ++ observeBotDirectMessage(from, sender_timestamp, from.id.pub_key, BOT_SENDER_KEY_PREFIX_LEN, text, pkt); + #endif + } + +@@ -850,7 +832,7 @@ void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uin + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + queueMessage(from, TXT_TYPE_SIGNED_PLAIN, pkt, sender_timestamp, sender_prefix, 4, text); + #if CMESH_BOT_ENABLED +- observeBotDirectMessage(from, sender_timestamp, sender_prefix, 4, text); ++ observeBotDirectMessage(from, sender_timestamp, sender_prefix, 4, text, pkt); + #endif + } + +@@ -1073,7 +1055,7 @@ bool MyMesh::handleBotCLI(const char *args) { + } + + void MyMesh::observeBotDirectMessage(const ContactInfo &from, uint32_t sender_timestamp, const uint8_t *sender_prefix, +- size_t sender_prefix_len, const char *text) { ++ size_t sender_prefix_len, const char *text, const mesh::Packet *packet) { + BotMessage message; + memset(&message, 0, sizeof(message)); + message.channel_kind = BotPolicy::classifyChannel(NULL, 0, true, bot_prefs); +@@ -1084,7 +1066,13 @@ void MyMesh::observeBotDirectMessage(const ContactInfo &from, uint32_t sender_ti + message.sender_key_prefix_len = prefix_len; + message.sender_timestamp = sender_timestamp; + message.received_at_timestamp = getRTCClock()->getCurrentTime(); +- message.packet_snr_quarters = (int8_t)0; ++ message.packet_snr_quarters = packet ? (int8_t)(packet->getSNR() * 4) : (int8_t)0; ++ if (packet && packet->getPathHashCount() == 0) { ++ float rssi = radio_driver.getLastRSSI(); ++ if (rssi > 32767.0f) rssi = 32767.0f; ++ if (rssi < -32768.0f) rssi = -32768.0f; ++ recordBotNeighbor(from.id.pub_key, (int16_t)rssi, (int8_t)(packet->getSNR() * 4)); ++ } + if (from.out_path_len != OUT_PATH_UNKNOWN && mesh::Packet::isValidPathLen(from.out_path_len)) { + message.path_len = from.out_path_len; + message.path_hash_size = (from.out_path_len >> 6) + 1; +@@ -1270,6 +1258,220 @@ BotCommandResult MyMesh::executeBotPrefixCommand(const BotCommand &command, char + return botWriteFormatted(output, output_len, "Prefix %s %s", key_hex, name); + } + ++BotCommandResult MyMesh::executeBotTimeCommand(const BotMessage &message, char *output, size_t output_len) { ++ uint32_t now = getRTCClock()->getCurrentTime(); ++ uint32_t uptime_seconds = _ms->getMillis() / 1000; ++ char time_str[16]; ++ if (now == 0) { ++ snprintf(time_str, sizeof(time_str), "not set"); ++ } else { ++ int64_t adjusted = (int64_t)now + (int64_t)BOT_LOCAL_TIME_OFFSET_SECONDS; ++ uint32_t seconds_of_day = (uint32_t)((adjusted % 86400LL + 86400LL) % 86400LL); ++ snprintf(time_str, sizeof(time_str), "%02lu:%02lu:%02lu", (unsigned long)(seconds_of_day / 3600UL), ++ (unsigned long)((seconds_of_day / 60UL) % 60UL), (unsigned long)(seconds_of_day % 60UL)); ++ } ++ uint32_t days = uptime_seconds / 86400UL; ++ uint32_t hours = (uptime_seconds / 3600UL) % 24UL; ++ uint32_t mins = (uptime_seconds / 60UL) % 60UL; ++ const char *target = message.sender_name[0] ? message.sender_name : NULL; ++ if (target) { ++ return botWriteFormatted(output, output_len, "Time @[%s] %s | up %lud %luh %lum", target, time_str, ++ (unsigned long)days, (unsigned long)hours, (unsigned long)mins); ++ } ++ return botWriteFormatted(output, output_len, "Time %s | up %lud %luh %lum", time_str, (unsigned long)days, ++ (unsigned long)hours, (unsigned long)mins); ++} ++ ++BotCommandResult MyMesh::executeBotLoraCommand(const BotMessage &message, char *output, size_t output_len) { ++ unsigned long freq_khz_total = (unsigned long)(_prefs.freq * 1000.0f + 0.5f); ++ unsigned long freq_mhz = freq_khz_total / 1000UL; ++ unsigned long freq_khz = freq_khz_total % 1000UL; ++ unsigned long bw_dh_total = (unsigned long)(_prefs.bw * 10.0f + 0.5f); ++ unsigned long bw_khz = bw_dh_total / 10UL; ++ unsigned long bw_dh = bw_dh_total % 10UL; ++ const char *target = message.sender_name[0] ? message.sender_name : NULL; ++ if (target) { ++ return botWriteFormatted(output, output_len, ++ "LoRa @[%s] %lu.%03luMHz SF%u BW%lu.%lukHz CR%u %+ddBm", target, freq_mhz, freq_khz, ++ (unsigned)_prefs.sf, bw_khz, bw_dh, (unsigned)_prefs.cr, (int)_prefs.tx_power_dbm); ++ } ++ return botWriteFormatted(output, output_len, "LoRa %lu.%03luMHz SF%u BW%lu.%lukHz CR%u %+ddBm", freq_mhz, freq_khz, ++ (unsigned)_prefs.sf, bw_khz, bw_dh, (unsigned)_prefs.cr, (int)_prefs.tx_power_dbm); ++} ++ ++BotCommandResult MyMesh::executeBotIdCommand(const BotMessage &message, char *output, size_t output_len) { ++ char key_hex[BOT_SENDER_KEY_PREFIX_LEN * 2 + 1]; ++ botFormatKeyPrefixHex(self_id.pub_key, key_hex, sizeof(key_hex)); ++ const char *name = _prefs.node_name[0] ? _prefs.node_name : "MeshCore bot"; ++ const char *target = message.sender_name[0] ? message.sender_name : NULL; ++ if (target) { ++ return botWriteFormatted(output, output_len, "Id @[%s] %s name %s", target, key_hex, name); ++ } ++ return botWriteFormatted(output, output_len, "Id %s name %s", key_hex, name); ++} ++ ++void MyMesh::recordBotNeighbor(const uint8_t *pub_key, int16_t rssi_dbm, int8_t snr_quarters) { ++ if (!pub_key) return; ++ size_t slot = BOT_NEIGHBOR_SLOTS; ++ size_t oldest_slot = 0; ++ uint32_t oldest_millis = 0; ++ bool found_oldest = false; ++ for (size_t i = 0; i < BOT_NEIGHBOR_SLOTS; i++) { ++ if (bot_neighbors[i].active && ++ memcmp(bot_neighbors[i].pub_key_prefix, pub_key, BOT_SENDER_KEY_PREFIX_LEN) == 0) { ++ slot = i; ++ break; ++ } ++ if (!bot_neighbors[i].active) { ++ if (slot == BOT_NEIGHBOR_SLOTS) slot = i; ++ } else if (!found_oldest || (int32_t)(bot_neighbors[i].last_heard_millis - oldest_millis) < 0) { ++ oldest_millis = bot_neighbors[i].last_heard_millis; ++ oldest_slot = i; ++ found_oldest = true; ++ } ++ } ++ if (slot == BOT_NEIGHBOR_SLOTS) slot = oldest_slot; ++ ++ bot_neighbors[slot].active = true; ++ memcpy(bot_neighbors[slot].pub_key_prefix, pub_key, BOT_SENDER_KEY_PREFIX_LEN); ++ bot_neighbors[slot].last_heard_millis = _ms->getMillis(); ++ bot_neighbors[slot].snr_quarters = snr_quarters; ++ bot_neighbors[slot].rssi_dbm = rssi_dbm; ++} ++ ++BotCommandResult MyMesh::executeBotNeighborsCommand(const BotMessage &message, char *output, size_t output_len) { ++ uint32_t now = _ms->getMillis(); ++ const char *target = message.sender_name[0] ? message.sender_name : NULL; ++ ++ // Build sorted index by recency. ++ uint8_t idx[BOT_NEIGHBOR_SLOTS]; ++ uint8_t count = 0; ++ for (size_t i = 0; i < BOT_NEIGHBOR_SLOTS; i++) { ++ if (!bot_neighbors[i].active) continue; ++ if ((int32_t)(now - bot_neighbors[i].last_heard_millis) > (int32_t)BOT_NEIGHBOR_RECENT_MILLIS) continue; ++ idx[count++] = (uint8_t)i; ++ } ++ for (uint8_t a = 0; a + 1 < count; a++) { ++ for (uint8_t b = a + 1; b < count; b++) { ++ if ((int32_t)(bot_neighbors[idx[b]].last_heard_millis - bot_neighbors[idx[a]].last_heard_millis) > 0) { ++ uint8_t tmp = idx[a]; ++ idx[a] = idx[b]; ++ idx[b] = tmp; ++ } ++ } ++ } ++ ++ if (count == 0) { ++ if (target) return botWriteFormatted(output, output_len, "Neighbors @[%s]: none heard recently", target); ++ return botWriteText(output, output_len, "Neighbors: none heard recently"); ++ } ++ ++ if (!output || output_len == 0) return botCommandResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ output[0] = 0; ++ int header_n = target ++ ? snprintf(output, output_len, "Neighbors @[%s]: ", target) ++ : snprintf(output, output_len, "Neighbors: "); ++ if (header_n < 0) return botCommandResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ size_t pos = (size_t)header_n; ++ bool truncated = pos >= output_len; ++ if (truncated) pos = output_len - 1; ++ ++ for (uint8_t k = 0; k < count; k++) { ++ const BotNeighbor &n = bot_neighbors[idx[k]]; ++ char hex_name[9]; ++ const char *display_name; ++ ContactInfo *contact = lookupContactByPubKey(n.pub_key_prefix, sizeof(n.pub_key_prefix)); ++ if (contact && contact->name[0]) { ++ display_name = contact->name; ++ } else { ++ static const char hex[] = "0123456789abcdef"; ++ for (size_t i = 0; i < 4; i++) { ++ hex_name[i * 2] = hex[n.pub_key_prefix[i] >> 4]; ++ hex_name[i * 2 + 1] = hex[n.pub_key_prefix[i] & 0x0F]; ++ } ++ hex_name[8] = 0; ++ display_name = hex_name; ++ } ++ char snr_str[8]; ++ botFormatQuarters(n.snr_quarters, snr_str, sizeof(snr_str)); ++ uint32_t ago_ms = now - n.last_heard_millis; ++ uint32_t ago_min = ago_ms / 60000UL; ++ char ago_buf[8]; ++ if (ago_min < 60) snprintf(ago_buf, sizeof(ago_buf), "%lum", (unsigned long)ago_min); ++ else if (ago_min < 1440) snprintf(ago_buf, sizeof(ago_buf), "%luh", (unsigned long)(ago_min / 60)); ++ else snprintf(ago_buf, sizeof(ago_buf), "%lud", (unsigned long)(ago_min / 1440)); ++ char entry[80]; ++ int entry_n = snprintf(entry, sizeof(entry), "%s%s %ddBm %s %s", k == 0 ? "" : ", ", display_name, ++ (int)n.rssi_dbm, snr_str, ago_buf); ++ if (entry_n < 0) break; ++ size_t entry_len = (size_t)entry_n; ++ size_t available = pos + 1 < output_len ? output_len - 1 - pos : 0; ++ if (entry_len > available) { ++ truncated = true; ++ break; ++ } ++ memcpy(&output[pos], entry, entry_len); ++ pos += entry_len; ++ output[pos] = 0; ++ } ++ ++ return botCommandResult(truncated ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, pos); ++} ++ ++bool MyMesh::dispatchBotTraceDirectLink(const BotMessage &message, const ContactInfo *direct_recipient, ++ uint8_t channel_idx, const BotCommand &command) { ++ char snr_str[8]; ++ botFormatQuarters(message.packet_snr_quarters, snr_str, sizeof(snr_str)); ++ const char *target = message.sender_name[0] ? message.sender_name : NULL; ++ char response[BOT_MAX_RESPONSE_LEN + 1]; ++ int written = ++ target ? snprintf(response, sizeof(response), "Trace @[%s] direct link, SNR %s (no repeaters to trace)", target, ++ snr_str) ++ : snprintf(response, sizeof(response), "Trace direct link, SNR %s (no repeaters to trace)", snr_str); ++ if (written < 0) return false; ++ size_t response_len = (size_t)written; ++ if (response_len >= sizeof(response)) response_len = sizeof(response) - 1; ++ ++ char final_response[BOT_MAX_RESPONSE_LEN + 1]; ++ size_t final_response_len = 0; ++ if (!botFormatResponseForChannel(message, response, response_len, final_response, sizeof(final_response), ++ &final_response_len)) { ++ bot_stats.send_failures++; ++ return true; ++ } ++ ++ BotFingerprint request_fingerprint = FirmwareBot::fingerprintFor(message); ++ BotFingerprint response_fingerprint = ++ FirmwareBot::responseFingerprintFor(message, final_response, final_response_len); ++ BotFingerprint fingerprint; ++ uint32_t due_at_millis = 0; ++ uint32_t now = _ms->getMillis(); ++ uint32_t bot_identity_seed; ++ memcpy(&bot_identity_seed, self_id.pub_key, sizeof(bot_identity_seed)); ++ uint32_t jitter_seed = (uint32_t)request_fingerprint.value ^ bot_identity_seed; ++ if (jitter_seed == 0) jitter_seed = 1; ++ uint8_t queue_depth = (uint8_t)_mgr->getOutboundTotal(); ++ BotCoordinatorScheduleResult schedule = ResponseCoordinator::schedule( ++ bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS, message, command.id, request_fingerprint, ++ response_fingerprint, now, jitter_seed, bot_identity_seed, queue_depth, bot_prefs.normal_delay_ms, ++ bot_prefs.normal_jitter_ms, bot_prefs.hop_step_ms, &fingerprint, &due_at_millis); ++ if (schedule == BOT_COORDINATOR_NO_SPACE || schedule == BOT_COORDINATOR_NOT_NORMAL) { ++ bot_stats.send_failures++; ++ return true; ++ } ++ ++ bot_stats.eligible_messages++; ++ bot_stats.pending_responses++; ++ FirmwareBot::recordCommandCooldown(bot_command_cooldowns, BOT_COMMAND_COOLDOWN_SLOTS, command.id, now, ++ BOT_TRACE_COOLDOWN_MILLIS); ++ if (!enqueueBotResponse(message, direct_recipient, channel_idx, final_response, final_response_len, fingerprint, ++ response_fingerprint)) { ++ ResponseCoordinator::cancel(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS, fingerprint); ++ bot_stats.send_failures++; ++ } ++ return true; ++} ++ + bool MyMesh::handleBotTraceCommand(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, + const BotCommand &command) { + uint8_t hash_size = command.args_len > 0 ? botConfiguredTraceHashSize(_prefs.path_hash_mode) : message.path_hash_size; +@@ -1277,9 +1479,14 @@ bool MyMesh::handleBotTraceCommand(const BotMessage &message, const ContactInfo + uint8_t flags = 0; + if (!botTraceFlagForHashSize(hash_size, &flags)) return false; + ++ if (command.args_len == 0 && message.path_hash_size == hash_size && message.path_hash_count == 0) { ++ return dispatchBotTraceDirectLink(message, direct_recipient, channel_idx, command); ++ } ++ + uint8_t path[BOT_MAX_PATH_BYTES]; + uint8_t path_len = 0; + bool have_path = false; ++ bool append_self = false; + if (command.args_len > 0) { + have_path = botParseTraceHexPath(command, flags, path, &path_len); + } else if (message.path_hash_size == hash_size) { +@@ -1292,13 +1499,20 @@ bool MyMesh::handleBotTraceCommand(const BotMessage &message, const ContactInfo + } else { + botCopyReversedPath(path, message.path, message.path_hash_count, hash_size); + } ++ append_self = true; + } + path_len = raw_len; + have_path = true; + } + } + +- if (!have_path) return false; ++ if (append_self && path_len >= hash_size) { ++ // Replace the final hop (the contact, who is typically a non-forwarding chat node) ++ // with the bot's own hash so the trace round-trips through the repeaters and ++ // onTraceRecv fires locally on the bot. ++ memcpy(&path[path_len - hash_size], self_id.pub_key, hash_size); ++ } ++ if (!have_path || !botTracePathShapeValid(path_len, flags)) return false; + + BotFingerprint request_fingerprint = FirmwareBot::fingerprintFor(message); + char response[BOT_MAX_RESPONSE_LEN + 1]; +@@ -1316,14 +1530,15 @@ bool MyMesh::handleBotTraceCommand(const BotMessage &message, const ContactInfo + uint32_t now = _ms->getMillis(); + uint32_t bot_identity_seed; + memcpy(&bot_identity_seed, self_id.pub_key, sizeof(bot_identity_seed)); +- uint32_t jitter_seed = request_fingerprint.value ? (uint32_t)request_fingerprint.value : 1; ++ uint32_t jitter_seed = (uint32_t)request_fingerprint.value ^ bot_identity_seed; ++ if (jitter_seed == 0) jitter_seed = 1; + uint8_t queue_depth = (uint8_t)_mgr->getOutboundTotal(); + BotCoordinatorScheduleResult schedule = ResponseCoordinator::schedule(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS, + message, command.id, request_fingerprint, + response_fingerprint, now, jitter_seed, + bot_identity_seed, queue_depth, bot_prefs.normal_delay_ms, +- bot_prefs.normal_jitter_ms, &fingerprint, +- &due_at_millis); ++ bot_prefs.normal_jitter_ms, bot_prefs.hop_step_ms, ++ &fingerprint, &due_at_millis); + if (schedule == BOT_COORDINATOR_NO_SPACE || schedule == BOT_COORDINATOR_NOT_NORMAL) { + bot_stats.send_failures++; + return true; +@@ -1372,6 +1587,7 @@ bool MyMesh::enqueueBotTrace(const BotMessage &message, const ContactInfo *direc + pending->channel_idx = channel_idx; + pending->channel_kind = message.channel_kind; + StrHelper::strzcpy(pending->channel_name, message.channel_name, sizeof(pending->channel_name)); ++ StrHelper::strzcpy(pending->target_name, message.sender_name, sizeof(pending->target_name)); + memcpy(pending->sender_key_prefix, message.sender_key_prefix, sizeof(pending->sender_key_prefix)); + pending->sender_key_prefix_len = message.sender_key_prefix_len; + pending->request_fingerprint = request_fingerprint; +@@ -1442,16 +1658,9 @@ bool MyMesh::sendPendingBotTrace(PendingBotTrace &pending, uint32_t now_millis) + sendDirect(pkt, pending.path, pending.path_len); + pending.sent = true; + pending.expires_at_millis = now_millis + BOT_TRACE_TIMEOUT_MILLIS; +- +- char response[BOT_MAX_RESPONSE_LEN + 1]; +- size_t response_len = botFormatTraceSent(response, sizeof(response), pending.path_len / botTraceHashSize(pending.flags)); +- char final_response[BOT_MAX_RESPONSE_LEN + 1]; +- size_t final_response_len = 0; +- if (!botFormatResponseForChannelKind(pending.channel_kind, response, response_len, final_response, sizeof(final_response), +- &final_response_len)) { +- return false; +- } +- return sendBotTraceText(pending, final_response, final_response_len, pending.response_fingerprint, now_millis); ++ ResponseCoordinator::recordRecent(bot_coordinator_recent, BOT_COORDINATOR_RECENT_SLOTS, ++ pending.response_fingerprint, now_millis); ++ return true; + } + + void MyMesh::expirePendingBotTraces(uint32_t now_millis) { +@@ -1460,8 +1669,26 @@ void MyMesh::expirePendingBotTraces(uint32_t now_millis) { + if (!pending->active || !pending->sent || (int32_t)(now_millis - pending->expires_at_millis) < 0) continue; + + char response[BOT_MAX_RESPONSE_LEN + 1]; +- uint8_t hop_count = pending->path_len / botTraceHashSize(pending->flags); +- snprintf(response, sizeof(response), "Trace timed out: no reply on %u-hop route", (unsigned)hop_count); ++ uint8_t hash_size = botTraceHashSize(pending->flags); ++ uint8_t hop_count = hash_size > 0 ? (uint8_t)(pending->path_len / hash_size) : 0; ++ bool has_target = pending->target_name[0] != 0; ++ if (hop_count == 0) { ++ if (has_target) { ++ snprintf(response, sizeof(response), "Trace @[%s] %08lx timed out, no reply on direct zero-hop route", ++ pending->target_name, (unsigned long)pending->tag); ++ } else { ++ snprintf(response, sizeof(response), "Trace %08lx timed out, no reply on direct zero-hop route", ++ (unsigned long)pending->tag); ++ } ++ } else { ++ if (has_target) { ++ snprintf(response, sizeof(response), "Trace @[%s] %08lx timed out, no reply on %u-hop route", ++ pending->target_name, (unsigned long)pending->tag, (unsigned)hop_count); ++ } else { ++ snprintf(response, sizeof(response), "Trace %08lx timed out, no reply on %u-hop route", ++ (unsigned long)pending->tag, (unsigned)hop_count); ++ } ++ } + char final_response[BOT_MAX_RESPONSE_LEN + 1]; + size_t final_response_len = 0; + if (botFormatResponseForChannelKind(pending->channel_kind, response, botBoundedStrLen(response, BOT_MAX_RESPONSE_LEN + 1), +@@ -1579,27 +1806,46 @@ void MyMesh::recordBotObservation(const BotMessage &message, const ContactInfo * + + BotCommandContext context; + buildBotCommandContext(context, command.id); ++ if (message.sender_name[0]) { ++ StrHelper::strzcpy(context.response_target, message.sender_name, sizeof(context.response_target)); ++ } + if (command.id == BOT_COMMAND_PATH || command.id == BOT_COMMAND_TRACE || command.id == BOT_COMMAND_TRACER) { + context.path_len = message.path_len; + context.path_hash_size = command.args_len > 0 ? botConfiguredTraceHashSize(_prefs.path_hash_mode) : message.path_hash_size; + context.path_hash_count = message.path_hash_count; + context.path_snr_quarters = message.packet_snr_quarters; +- if (message.channel_kind != BOT_CHANNEL_DM && message.sender_name[0]) { +- StrHelper::strzcpy(context.response_target, message.sender_name, sizeof(context.response_target)); +- } + context.path = message.path; + } + if (!result_ready) { +- if (command.id == BOT_COMMAND_TEST) { +- size_t written = 0; +- BotWriteResult write_result = FirmwareBot::writeAckResponse(message, command, response, sizeof(response), &written); +- result.code = write_result == BOT_WRITE_NO_SPACE ? BOT_COMMAND_RESULT_NO_SPACE +- : (write_result == BOT_WRITE_TRUNCATED ? BOT_COMMAND_RESULT_TRUNCATED +- : BOT_COMMAND_RESULT_OK); +- result.text_len = written; +- } else { +- result = command.id == BOT_COMMAND_PREFIX ? executeBotPrefixCommand(command, response, sizeof(response)) +- : BotCommands::executeCommand(command, context, response, sizeof(response)); ++ switch (command.id) { ++ case BOT_COMMAND_TEST: { ++ size_t written = 0; ++ BotWriteResult write_result = FirmwareBot::writeAckResponse(message, command, response, sizeof(response), &written); ++ result.code = write_result == BOT_WRITE_NO_SPACE ++ ? BOT_COMMAND_RESULT_NO_SPACE ++ : (write_result == BOT_WRITE_TRUNCATED ? BOT_COMMAND_RESULT_TRUNCATED ++ : BOT_COMMAND_RESULT_OK); ++ result.text_len = written; ++ break; ++ } ++ case BOT_COMMAND_PREFIX: ++ result = executeBotPrefixCommand(command, response, sizeof(response)); ++ break; ++ case BOT_COMMAND_TIME: ++ result = executeBotTimeCommand(message, response, sizeof(response)); ++ break; ++ case BOT_COMMAND_LORA: ++ result = executeBotLoraCommand(message, response, sizeof(response)); ++ break; ++ case BOT_COMMAND_ID: ++ result = executeBotIdCommand(message, response, sizeof(response)); ++ break; ++ case BOT_COMMAND_NEIGHBORS: ++ result = executeBotNeighborsCommand(message, response, sizeof(response)); ++ break; ++ default: ++ result = BotCommands::executeCommand(command, context, response, sizeof(response)); ++ break; + } + } + if (result.code == BOT_COMMAND_RESULT_NOT_HANDLED || result.code == BOT_COMMAND_RESULT_NO_SPACE || result.text_len == 0) { +@@ -1620,14 +1866,16 @@ void MyMesh::recordBotObservation(const BotMessage &message, const ContactInfo * + uint32_t due_at_millis = 0; + uint32_t bot_identity_seed; + memcpy(&bot_identity_seed, self_id.pub_key, sizeof(bot_identity_seed)); ++ uint32_t jitter_seed = (uint32_t)request_fingerprint.value ^ bot_identity_seed ^ context.random_seed; ++ if (jitter_seed == 0) jitter_seed = 1; + uint8_t queue_depth = (uint8_t)_mgr->getOutboundTotal(); + BotCoordinatorScheduleResult schedule = ResponseCoordinator::schedule(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS, + message, command.id, request_fingerprint, + response_fingerprint, _ms->getMillis(), +- context.random_seed, bot_identity_seed, ++ jitter_seed, bot_identity_seed, + queue_depth, bot_prefs.normal_delay_ms, +- bot_prefs.normal_jitter_ms, &fingerprint, +- &due_at_millis); ++ bot_prefs.normal_jitter_ms, bot_prefs.hop_step_ms, ++ &fingerprint, &due_at_millis); + if (schedule == BOT_COORDINATOR_NO_SPACE || schedule == BOT_COORDINATOR_NOT_NORMAL) { + bot_stats.send_failures++; + return; +@@ -2102,8 +2350,11 @@ void MyMesh::onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, + if (!pending->active || !pending->sent || pending->tag != tag || pending->auth_code != auth_code) continue; + + char response[BOT_MAX_RESPONSE_LEN + 1]; +- size_t response_len = botFormatTraceResult(response, sizeof(response), tag, flags, path_hashes, path_len, +- (int8_t)(packet->getSNR() * 4)); ++ uint8_t result_hash_size = botTraceHashSize(flags); ++ uint8_t result_hop_count = result_hash_size > 0 ? (uint8_t)(path_len / result_hash_size) : 0; ++ size_t response_len = BotCommands::formatTraceResult(response, sizeof(response), pending->target_name, tag, ++ result_hash_size, path_snrs, path_hashes, result_hop_count, ++ (int8_t)(packet->getSNR() * 4)); + char final_response[BOT_MAX_RESPONSE_LEN + 1]; + size_t final_response_len = 0; + if (botFormatResponseForChannelKind(pending->channel_kind, response, response_len, final_response, +@@ -2149,6 +2400,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe + memset(pending_bot_traces, 0, sizeof(pending_bot_traces)); + memset(pending_emergency_forwards, 0, sizeof(pending_emergency_forwards)); + memset(bot_command_cooldowns, 0, sizeof(bot_command_cooldowns)); ++ memset(bot_neighbors, 0, sizeof(bot_neighbors)); + ResponseCoordinator::clear(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS); + ResponseCoordinator::clearRecent(bot_coordinator_recent, BOT_COORDINATOR_RECENT_SLOTS); + KnownBotRegistry::clear(known_bot_entries, BOT_KNOWN_BOT_SLOTS); +diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h +index 67bdc010..9685e89a 100644 +--- a/examples/companion_radio/MyMesh.h ++++ b/examples/companion_radio/MyMesh.h +@@ -209,7 +209,7 @@ private: + void printBotPrefs(); + bool handleBotCLI(const char *args); + void observeBotDirectMessage(const ContactInfo &from, uint32_t sender_timestamp, const uint8_t *sender_prefix, +- size_t sender_prefix_len, const char *text); ++ size_t sender_prefix_len, const char *text, const mesh::Packet *packet); + void observeBotChannelMessage(uint8_t channel_idx, const char *channel_name, const char *text, + uint32_t sender_timestamp, const mesh::Packet *packet); + void recordBotObservation(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx); +@@ -222,7 +222,14 @@ private: + BotFingerprint response_fingerprint); + bool handleBotTraceCommand(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, + const BotCommand &command); ++ bool dispatchBotTraceDirectLink(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, ++ const BotCommand &command); + BotCommandResult executeBotPrefixCommand(const BotCommand &command, char *output, size_t output_len); ++ BotCommandResult executeBotTimeCommand(const BotMessage &message, char *output, size_t output_len); ++ BotCommandResult executeBotLoraCommand(const BotMessage &message, char *output, size_t output_len); ++ BotCommandResult executeBotIdCommand(const BotMessage &message, char *output, size_t output_len); ++ BotCommandResult executeBotNeighborsCommand(const BotMessage &message, char *output, size_t output_len); ++ void recordBotNeighbor(const uint8_t *pub_key, int16_t rssi_dbm, int8_t snr_quarters); + bool enqueueBotTrace(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, + const uint8_t *path, uint8_t path_len, uint8_t flags, BotFingerprint request_fingerprint, + BotFingerprint response_fingerprint, uint32_t tag, uint32_t auth_code); +@@ -298,6 +305,7 @@ private: + uint8_t recipient_pub_key[PUB_KEY_SIZE]; + uint8_t channel_idx; + char channel_name[BOT_MAX_CHANNEL_NAME_LEN + 1]; ++ char target_name[BOT_MAX_SENDER_NAME_LEN + 1]; + uint8_t sender_key_prefix[BOT_SENDER_KEY_PREFIX_LEN]; + uint8_t sender_key_prefix_len; + BotFingerprint request_fingerprint; +@@ -319,6 +327,7 @@ private: + BotCoordinatorPending bot_coordinator_pending[BOT_COORDINATOR_PENDING_SLOTS]; + BotCoordinatorRecent bot_coordinator_recent[BOT_COORDINATOR_RECENT_SLOTS]; + BotKnownBotEntry known_bot_entries[BOT_KNOWN_BOT_SLOTS]; ++ BotNeighbor bot_neighbors[BOT_NEIGHBOR_SLOTS]; + unsigned long next_bot_local_advert; + unsigned long next_bot_flood_advert; + #endif +diff --git a/examples/companion_radio/ResponseCoordinator.cpp b/examples/companion_radio/ResponseCoordinator.cpp +index 484372e5..7afdab24 100644 +--- a/examples/companion_radio/ResponseCoordinator.cpp ++++ b/examples/companion_radio/ResponseCoordinator.cpp +@@ -21,12 +21,11 @@ uint32_t channelDelayBias(BotChannelKind kind) { + return 800; + } + +-uint32_t commandDelayBias(BotCommandId command_id) { +- if (command_id == BOT_COMMAND_PING || command_id == BOT_COMMAND_TEST) return 0; +- if (command_id == BOT_COMMAND_ROLL || command_id == BOT_COMMAND_DICE) return 200; +- if (command_id == BOT_COMMAND_STATUS || command_id == BOT_COMMAND_CHANNELS) return 400; +- if (command_id == BOT_COMMAND_TRACE || command_id == BOT_COMMAND_TRACER) return 800; +- return 100; ++uint32_t hopDelayBias(BotChannelKind kind, uint8_t path_hash_count, uint16_t hop_step_millis) { ++ if (kind == BOT_CHANNEL_DM) return 0; ++ uint32_t bias = (uint32_t)path_hash_count * (uint32_t)hop_step_millis; ++ if (bias > BOT_HOP_BIAS_MAX_MILLIS) bias = BOT_HOP_BIAS_MAX_MILLIS; ++ return bias; + } + + uint32_t tieBreakBias(BotFingerprint request_fingerprint, uint32_t bot_identity_seed) { +@@ -62,14 +61,24 @@ void clearRecent(BotCoordinatorRecent recent[], size_t recent_count) { + uint32_t responseDelayMillis(const BotMessage& message, BotCommandId command_id, BotFingerprint request_fingerprint, + uint32_t bot_identity_seed, uint8_t queue_depth, uint32_t jitter_seed) { + return responseDelayMillis(message, command_id, request_fingerprint, bot_identity_seed, queue_depth, jitter_seed, +- BOT_RESPONSE_DELAY_BASE_MILLIS, BOT_RESPONSE_DELAY_JITTER_MILLIS); ++ BOT_RESPONSE_DELAY_BASE_MILLIS, BOT_RESPONSE_DELAY_JITTER_MILLIS, ++ BOT_HOP_STEP_MILLIS_DEFAULT); + } + + uint32_t responseDelayMillis(const BotMessage& message, BotCommandId command_id, BotFingerprint request_fingerprint, + uint32_t bot_identity_seed, uint8_t queue_depth, uint32_t jitter_seed, + uint16_t base_delay_millis, uint16_t jitter_millis) { ++ return responseDelayMillis(message, command_id, request_fingerprint, bot_identity_seed, queue_depth, jitter_seed, ++ base_delay_millis, jitter_millis, BOT_HOP_STEP_MILLIS_DEFAULT); ++} ++ ++uint32_t responseDelayMillis(const BotMessage& message, BotCommandId command_id, BotFingerprint request_fingerprint, ++ uint32_t bot_identity_seed, uint8_t queue_depth, uint32_t jitter_seed, ++ uint16_t base_delay_millis, uint16_t jitter_millis, uint16_t hop_step_millis) { ++ (void)command_id; + uint32_t jitter = jitter_millis ? jitter_seed % jitter_millis : 0; +- return (uint32_t)base_delay_millis + channelDelayBias(message.channel_kind) + commandDelayBias(command_id) + ++ return (uint32_t)base_delay_millis + channelDelayBias(message.channel_kind) + ++ hopDelayBias(message.channel_kind, message.path_hash_count, hop_step_millis) + + queueDelayBias(queue_depth) + tieBreakBias(request_fingerprint, bot_identity_seed) + jitter; + } + +@@ -80,7 +89,7 @@ BotCoordinatorScheduleResult schedule(BotCoordinatorPending pending[], size_t pe + uint8_t queue_depth, BotFingerprint* fingerprint, uint32_t* due_at_millis) { + return schedule(pending, pending_count, message, command_id, request_fingerprint, response_fingerprint, now_millis, + jitter_seed, bot_identity_seed, queue_depth, BOT_RESPONSE_DELAY_BASE_MILLIS, +- BOT_RESPONSE_DELAY_JITTER_MILLIS, fingerprint, due_at_millis); ++ BOT_RESPONSE_DELAY_JITTER_MILLIS, BOT_HOP_STEP_MILLIS_DEFAULT, fingerprint, due_at_millis); + } + + BotCoordinatorScheduleResult schedule(BotCoordinatorPending pending[], size_t pending_count, +@@ -89,12 +98,24 @@ BotCoordinatorScheduleResult schedule(BotCoordinatorPending pending[], size_t pe + uint32_t now_millis, uint32_t jitter_seed, uint32_t bot_identity_seed, + uint8_t queue_depth, uint16_t base_delay_millis, uint16_t jitter_millis, + BotFingerprint* fingerprint, uint32_t* due_at_millis) { ++ return schedule(pending, pending_count, message, command_id, request_fingerprint, response_fingerprint, now_millis, ++ jitter_seed, bot_identity_seed, queue_depth, base_delay_millis, jitter_millis, ++ BOT_HOP_STEP_MILLIS_DEFAULT, fingerprint, due_at_millis); ++} ++ ++BotCoordinatorScheduleResult schedule(BotCoordinatorPending pending[], size_t pending_count, ++ const BotMessage& message, BotCommandId command_id, ++ BotFingerprint request_fingerprint, BotFingerprint response_fingerprint, ++ uint32_t now_millis, uint32_t jitter_seed, uint32_t bot_identity_seed, ++ uint8_t queue_depth, uint16_t base_delay_millis, uint16_t jitter_millis, ++ uint16_t hop_step_millis, BotFingerprint* fingerprint, ++ uint32_t* due_at_millis) { + if (fingerprint) fingerprint->value = 0; + if (due_at_millis) *due_at_millis = 0; + if (!pending || pending_count == 0 || !isNormalChannel(message.channel_kind) || request_fingerprint.value == 0 || response_fingerprint.value == 0) return BOT_COORDINATOR_NOT_NORMAL; + + uint32_t due = now_millis + responseDelayMillis(message, command_id, request_fingerprint, bot_identity_seed, queue_depth, +- jitter_seed, base_delay_millis, jitter_millis); ++ jitter_seed, base_delay_millis, jitter_millis, hop_step_millis); + size_t slot = pending_count; + + for (size_t i = 0; i < pending_count; i++) { +@@ -107,8 +128,10 @@ BotCoordinatorScheduleResult schedule(BotCoordinatorPending pending[], size_t pe + if (slot == pending_count) return BOT_COORDINATOR_NO_SPACE; + + bool replaced = pending[slot].active; ++ bool preserve_suppressed = replaced && pending[slot].suppressed && ++ sameFingerprint(pending[slot].request_fingerprint, request_fingerprint); + pending[slot].active = true; +- pending[slot].suppressed = false; ++ pending[slot].suppressed = preserve_suppressed; + pending[slot].request_fingerprint = request_fingerprint; + pending[slot].response_fingerprint = response_fingerprint; + pending[slot].due_at_millis = due; +diff --git a/examples/companion_radio/ResponseCoordinator.h b/examples/companion_radio/ResponseCoordinator.h +index 7717bee7..f549b887 100644 +--- a/examples/companion_radio/ResponseCoordinator.h ++++ b/examples/companion_radio/ResponseCoordinator.h +@@ -11,6 +11,9 @@ uint32_t responseDelayMillis(const BotMessage& message, BotCommandId command_id, + uint32_t responseDelayMillis(const BotMessage& message, BotCommandId command_id, BotFingerprint request_fingerprint, + uint32_t bot_identity_seed, uint8_t queue_depth, uint32_t jitter_seed, + uint16_t base_delay_millis, uint16_t jitter_millis); ++uint32_t responseDelayMillis(const BotMessage& message, BotCommandId command_id, BotFingerprint request_fingerprint, ++ uint32_t bot_identity_seed, uint8_t queue_depth, uint32_t jitter_seed, ++ uint16_t base_delay_millis, uint16_t jitter_millis, uint16_t hop_step_millis); + BotCoordinatorScheduleResult schedule(BotCoordinatorPending pending[], size_t pending_count, + const BotMessage& message, BotCommandId command_id, + BotFingerprint request_fingerprint, BotFingerprint response_fingerprint, +@@ -22,6 +25,12 @@ BotCoordinatorScheduleResult schedule(BotCoordinatorPending pending[], size_t pe + uint32_t now_millis, uint32_t jitter_seed, uint32_t bot_identity_seed, + uint8_t queue_depth, uint16_t base_delay_millis, uint16_t jitter_millis, + BotFingerprint* fingerprint, uint32_t* due_at_millis); ++BotCoordinatorScheduleResult schedule(BotCoordinatorPending pending[], size_t pending_count, ++ const BotMessage& message, BotCommandId command_id, ++ BotFingerprint request_fingerprint, BotFingerprint response_fingerprint, ++ uint32_t now_millis, uint32_t jitter_seed, uint32_t bot_identity_seed, ++ uint8_t queue_depth, uint16_t base_delay_millis, uint16_t jitter_millis, ++ uint16_t hop_step_millis, BotFingerprint* fingerprint, uint32_t* due_at_millis); + bool suppress(BotCoordinatorPending pending[], size_t pending_count, BotFingerprint response_fingerprint); + bool cancel(BotCoordinatorPending pending[], size_t pending_count, BotFingerprint request_fingerprint); + BotCoordinatorReady poll(BotCoordinatorPending pending[], size_t pending_count, uint32_t now_millis); +diff --git a/src/Mesh.cpp b/src/Mesh.cpp +index 57fee140..9c512b20 100644 +--- a/src/Mesh.cpp ++++ b/src/Mesh.cpp +@@ -53,12 +53,22 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { + uint8_t offset = pkt->path_len << path_sz; + if (offset >= len) { // TRACE has reached end of given path + onTraceRecv(pkt, trace_tag, auth_code, flags, pkt->path, &pkt->payload[i], len); +- } else if (self_id.isHashMatch(&pkt->payload[i + offset], 1 << path_sz) && allowPacketForward(pkt) && !_tables->hasSeen(pkt)) { ++ } else if (self_id.isHashMatch(&pkt->payload[i + offset], 1 << path_sz) && !_tables->hasSeen(pkt)) { + // append SNR (Not hash!) + pkt->path[pkt->path_len++] = (int8_t) (pkt->getSNR()*4); + +- uint32_t d = getDirectRetransmitDelay(pkt); +- return ACTION_RETRANSMIT_DELAYED(5, d); // schedule with priority 5 (for now), maybe make configurable? ++ uint8_t new_offset = pkt->path_len << path_sz; ++ if (new_offset >= len) { ++ // We are the last hop in the path; fire onTraceRecv locally so a node ++ // that originated the trace can receive its own collected SNR data. ++ onTraceRecv(pkt, trace_tag, auth_code, flags, pkt->path, &pkt->payload[i], len); ++ return ACTION_RELEASE; ++ } ++ ++ if (allowPacketForward(pkt)) { ++ uint32_t d = getDirectRetransmitDelay(pkt); ++ return ACTION_RETRANSMIT_DELAYED(5, d); // schedule with priority 5 (for now), maybe make configurable? ++ } + } + } + return ACTION_RELEASE; diff --git a/tests/firmware_bot/run_tests.py b/tests/firmware_bot/run_tests.py index ac55024..decc2c4 100644 --- a/tests/firmware_bot/run_tests.py +++ b/tests/firmware_bot/run_tests.py @@ -17,6 +17,7 @@ def main(): "-Wall", "-Wextra", "-Werror", + "-DBOT_LOCAL_TIME_OFFSET_SECONDS=-21600", "-I", str(SRC_DIR), str(ROOT / "tests" / "firmware_bot" / "test_firmware_bot.cpp"), diff --git a/tests/firmware_bot/test_firmware_bot.cpp b/tests/firmware_bot/test_firmware_bot.cpp index 1e0b2fd..75aadc3 100644 --- a/tests/firmware_bot/test_firmware_bot.cpp +++ b/tests/firmware_bot/test_firmware_bot.cpp @@ -1,5 +1,7 @@ #include +#include #include +#include #include #include "BotCommandRegistry.h" @@ -11,10 +13,46 @@ #include "KnownBotRegistry.h" #include "ResponseCoordinator.h" +static void assert_starts_with(const char* value, const char* prefix) { + assert(value != NULL); + assert(prefix != NULL); + assert(strncmp(value, prefix, strlen(prefix)) == 0); +} + +static void assert_contains(const char* value, const char* needle) { + assert(value != NULL); + assert(needle != NULL); + assert(strstr(value, needle) != NULL); +} + +static unsigned parse_unsigned_suffix(const char* value, size_t offset) { + assert(value != NULL); + assert(offset < strlen(value)); + unsigned parsed = 0; + for (size_t i = offset; value[i] != 0; i++) { + assert(isdigit((unsigned char)value[i])); + parsed = parsed * 10 + (unsigned)(value[i] - '0'); + } + return parsed; +} + +static void assert_roll_range(const char* value, const char* prefix, unsigned low, unsigned high) { + assert_starts_with(value, prefix); + unsigned rolled = parse_unsigned_suffix(value, strlen(prefix)); + assert(rolled >= low); + assert(rolled <= high); +} + +static void assert_dice_single_range(const char* value, const char* prefix, unsigned high) { + assert_starts_with(value, prefix); + unsigned rolled = parse_unsigned_suffix(value, strlen(prefix)); + assert(rolled >= 1); + assert(rolled <= high); +} + static void test_command_registry() { assert(BotCommandRegistry::commandCount() >= 18); uint32_t seen_masks = 0; - bool saw_hidden = false; bool saw_internal = false; for (size_t i = 0; i < BotCommandRegistry::commandCount(); i++) { const BotCommandMetadata* command = BotCommandRegistry::commandAt(i); @@ -35,19 +73,22 @@ static void test_command_registry() { } else { assert(command->mask == 0); assert(!BotCommandRegistry::isDiscoverable(command->id)); - saw_hidden = saw_hidden || command->visibility == BOT_COMMAND_VISIBILITY_HIDDEN; + assert(command->id != BOT_COMMAND_UNSUPPORTED); saw_internal = saw_internal || command->visibility == BOT_COMMAND_VISIBILITY_INTERNAL; } } assert((seen_masks & BOT_COMMAND_MASK_ALL) == BOT_COMMAND_MASK_ALL); - assert(saw_hidden); assert(saw_internal); assert(BotCommandRegistry::findByName("ROLL", 4)->id == BOT_COMMAND_ROLL); assert(BotCommandRegistry::findByName("dice", 4)->id == BOT_COMMAND_DICE); assert(BotCommandRegistry::findByName("commands", 8)->id == BOT_COMMAND_CMD); + assert(BotCommandRegistry::findByName("channel", 7)->id == BOT_COMMAND_CHANNELS); assert(BotCommandRegistry::findByName("t", 1)->id == BOT_COMMAND_TEST); assert(BotCommandRegistry::findByName("tracer", 6)->id == BOT_COMMAND_TRACER); - assert(BotCommandRegistry::findByName("weather", 7)->id == BOT_COMMAND_UNSUPPORTED); + assert(BotCommandRegistry::findByName("lookup", 6)->id == BOT_COMMAND_PREFIX); + assert(BotCommandRegistry::findByName("weather", 7) == NULL); + assert(BotCommandRegistry::findByName("reboot", 6) == NULL); + assert(BotCommandRegistry::findByName("nodes", 5) == NULL); assert(BotCommandRegistry::findByName("nope", 4) == NULL); assert(strcmp(BotCommandRegistry::commandName(BOT_COMMAND_ROLL), "roll") == 0); assert(BotCommandRegistry::commandMask(BOT_COMMAND_UNKNOWN) == 0); @@ -90,6 +131,7 @@ static void test_bot_prefs_defaults() { assert(prefs.enabled); assert(prefs.normal_delay_ms == BOT_RESPONSE_DELAY_BASE_MILLIS); assert(prefs.normal_jitter_ms == BOT_RESPONSE_DELAY_JITTER_MILLIS); + assert(prefs.hop_step_ms == BOT_HOP_STEP_MILLIS_DEFAULT); assert(prefs.local_advert_interval_ms == BOT_PREFS_DEFAULT_LOCAL_ADVERT_MILLIS); assert(prefs.flood_advert_interval_ms == BOT_PREFS_DEFAULT_FLOOD_ADVERT_MILLIS); assert(strcmp(prefs.bot_channel, "#bot") == 0); @@ -107,6 +149,7 @@ static void test_bot_prefs_serialization_round_trip() { prefs.enabled = false; prefs.normal_delay_ms = 2345; prefs.normal_jitter_ms = 6789; + prefs.hop_step_ms = 7500; prefs.local_advert_interval_ms = 123456; prefs.flood_advert_interval_ms = 654321; strncpy(prefs.bot_channel, "#ops", sizeof(prefs.bot_channel) - 1); @@ -121,6 +164,7 @@ static void test_bot_prefs_serialization_round_trip() { assert(!loaded.enabled); assert(loaded.normal_delay_ms == 2345); assert(loaded.normal_jitter_ms == 6789); + assert(loaded.hop_step_ms == 7500); assert(loaded.local_advert_interval_ms == 123456); assert(loaded.flood_advert_interval_ms == 654321); assert(strcmp(loaded.bot_channel, "#ops") == 0); @@ -199,11 +243,16 @@ static void test_bot_prefs_validation_and_command_mask() { assert(id == BOT_COMMAND_MAGIC8); assert(BotPrefsCodec::commandIdForName("p", &id)); assert(id == BOT_COMMAND_PATH); + assert(BotPrefsCodec::commandIdForName("channel", &id)); + assert(id == BOT_COMMAND_CHANNELS); assert(BotPrefsCodec::commandIdForName("tracer", &id)); assert(id == BOT_COMMAND_TRACER); assert(!BotPrefsCodec::commandIdForName("weather", &id)); + assert(!BotPrefsCodec::commandIdForName("reboot", &id)); assert(BotPrefsCodec::commandIdForName("prefix", &id)); assert(id == BOT_COMMAND_PREFIX); + assert(BotPrefsCodec::commandIdForName("lookup", &id)); + assert(id == BOT_COMMAND_PREFIX); assert(!BotPrefsCodec::commandIdForName("unknown", &id)); assert(!BotPrefsCodec::commandIdForName("nope", &id)); } @@ -359,6 +408,9 @@ static void test_parse_command() { assert(FirmwareBot::parseCommand("!channels", 9, &command)); assert(command.id == BOT_COMMAND_CHANNELS); + assert(FirmwareBot::parseCommand("!channel list", 13, &command)); + assert(command.id == BOT_COMMAND_CHANNELS); + assert(strcmp(command.args, "list") == 0); assert(FirmwareBot::parseCommand("!cmd", 4, &command)); assert(command.id == BOT_COMMAND_CMD); @@ -385,10 +437,19 @@ static void test_parse_command() { assert(FirmwareBot::parseCommand("!prefix 01020304", 16, &command)); assert(command.id == BOT_COMMAND_PREFIX); assert(strcmp(command.args, "01020304") == 0); + assert(FirmwareBot::parseCommand("!lookup 0102", 12, &command)); + assert(command.id == BOT_COMMAND_PREFIX); + assert(strcmp(command.args, "0102") == 0); + assert(FirmwareBot::parseCommand("!trace 01,7a,55", 15, &command)); + assert(command.id == BOT_COMMAND_TRACE); + assert(strcmp(command.args, "01,7a,55") == 0); + assert(FirmwareBot::parseCommand("!tracer 01,7a,55", 16, &command)); + assert(command.id == BOT_COMMAND_TRACER); + assert(strcmp(command.args, "01,7a,55") == 0); assert(FirmwareBot::parseCommand("!weather", 8, &command)); - assert(command.id == BOT_COMMAND_UNSUPPORTED); + assert(command.id == BOT_COMMAND_UNKNOWN); assert(FirmwareBot::parseCommand("!reboot now", 11, &command)); - assert(command.id == BOT_COMMAND_UNSUPPORTED); + assert(command.id == BOT_COMMAND_UNKNOWN); assert(strcmp(command.name, "reboot") == 0); assert(strcmp(command.args, "now") == 0); assert(!FirmwareBot::parseCommand("weather", 7, &command, true)); @@ -463,6 +524,48 @@ static void test_channel_text_and_response_guards() { assert(written == BOT_MAX_GROUP_RESPONSE_LEN); } +static void test_ack_response_format() { + BotMessage message; + memset(&message, 0, sizeof(message)); + message.channel_kind = BOT_CHANNEL_BOT; + strncpy(message.channel_name, "#bot", sizeof(message.channel_name) - 1); + strncpy(message.sender_name, "alice", sizeof(message.sender_name) - 1); + message.sender_timestamp = 12345; + message.received_at_timestamp = 12345; + strncpy(message.text, "!test hello", sizeof(message.text) - 1); + message.text_len = strlen(message.text); + BotCommand command; + assert(FirmwareBot::parseCommand(message.text, message.text_len, &command)); + char out[BOT_MAX_RESPONSE_LEN + 1]; + size_t written = 0; + assert(FirmwareBot::writeAckResponse(message, command, out, sizeof(out), &written) == BOT_WRITE_OK); + assert(strcmp(out, "ack @[alice] #bot | 0h SNR 0.00 | recv 21:25:45 | hello") == 0); + assert(written == strlen(out)); + + message.channel_kind = BOT_CHANNEL_DM; + message.sender_name[0] = 0; + message.channel_name[0] = 0; + assert(FirmwareBot::parseCommand("!t", 2, &command)); + assert(FirmwareBot::writeAckResponse(message, command, out, sizeof(out), &written) == BOT_WRITE_OK); + assert(strcmp(out, "ack @[unknown] direct | 0h SNR 0.00 | recv 21:25:45") == 0); + + uint8_t path[] = { 0x12, 0x34, 0xab, 0xcd }; + message.channel_kind = BOT_CHANNEL_BOT; + strncpy(message.channel_name, "#bot", sizeof(message.channel_name) - 1); + strncpy(message.sender_name, "bob", sizeof(message.sender_name) - 1); + message.path = path; + message.path_hash_size = 2; + message.path_hash_count = 2; + message.packet_snr_quarters = -5; + assert(FirmwareBot::writeAckResponse(message, command, out, sizeof(out), &written) == BOT_WRITE_OK); + assert(strcmp(out, "ack @[bob] #bot | 2h@2B SNR -1.25 | recv 21:25:45") == 0); + assert(strstr(out, "Bot test OK") == NULL); + + char small[16]; + assert(FirmwareBot::writeAckResponse(message, command, small, sizeof(small), &written) == BOT_WRITE_TRUNCATED); + assert(strncmp(small, "ack @[bob]", 10) == 0); +} + static void test_bot_advert_marker() { char out[32]; size_t written = 0; @@ -487,6 +590,7 @@ static BotMessage make_message(const char* channel, const char* text) { strncpy(message.sender_name, "alice", sizeof(message.sender_name) - 1); for (size_t i = 0; i < sizeof(message.sender_key_prefix); i++) message.sender_key_prefix[i] = (uint8_t)(i + 1); message.sender_timestamp = 12345; + message.received_at_timestamp = 12345; strncpy(message.text, text, sizeof(message.text) - 1); message.text_len = strlen(message.text); return message; @@ -540,72 +644,95 @@ static void test_command_outputs() { result = run_command("!test", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strcmp(out, "Bot test OK") == 0); + assert_starts_with(out, "ack @["); + assert_contains(out, "recv "); + assert(strstr(out, "Bot test OK") == NULL); result = run_command("!hello", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); assert(strcmp(out, "Hello from cm-bot") == 0); + { + BotCommand command; + assert(FirmwareBot::parseCommand("!hello", 6, &command)); + BotCommandContext context = make_context(); + strncpy(context.response_target, "alice", sizeof(context.response_target) - 1); + result = BotCommands::executeCommand(command, context, out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Hello @[alice], from cm-bot") == 0); + } + result = run_command("!about", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); assert(strstr(out, "Colorado Mesh firmware bot") != NULL); result = run_command("!help", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strstr(out, "Cmds: help cmd ping") == out); + assert_starts_with(out, "Commands: help, cmd, ping"); assert(result.text_len <= BOT_MAX_GROUP_RESPONSE_LEN); - assert(strstr(out, "roll") != NULL); - assert(strstr(out, "dice") != NULL); - assert(strstr(out, "trace") != NULL); - assert(strstr(out, "tracer") != NULL); - assert(strstr(out, "prefix") != NULL); - assert(strstr(out, "help ") != NULL); + assert_contains(out, "roll"); + assert_contains(out, "dice"); + assert_contains(out, "trace"); + assert_contains(out, "tracer"); + assert_contains(out, "prefix"); + assert_contains(out, "help "); assert(strstr(out, "weather") == NULL); result = run_command("!cmd", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strstr(out, "help cmd ping test hello about roll dice") == out); - assert(strstr(out, "trace") != NULL); - assert(strstr(out, "tracer") != NULL); - assert(strstr(out, "prefix") != NULL); + assert_starts_with(out, "Commands: test, ping, help, hello, about"); + assert_contains(out, "prefix"); + assert_contains(out, "path"); + assert_contains(out, "dice"); + assert_contains(out, "roll"); + assert_contains(out, "stats"); + assert_contains(out, "time"); + assert_contains(out, "lora"); + assert_contains(out, "id"); + assert_contains(out, "neighbors"); + assert_contains(out, ", "); assert(strstr(out, "weather") == NULL); result = run_command("!help roll", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strstr(out, "roll: Roll within a numeric range.") == out); - assert(strstr(out, "Usage: roll [max|low high]") != NULL); + assert_starts_with(out, "roll: Roll a random number between 1 and X"); + assert_contains(out, "Usage: roll [max|low high]"); result = run_command("!help dice", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strstr(out, "dice: Roll dice notation") == out); - assert(strstr(out, "Usage: dice [dN|NdN]") != NULL); + assert_starts_with(out, "dice: Roll dice for tabletop games"); + assert_contains(out, "Usage: dice [NdX|dX|decade]"); result = run_command("!help trace", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strstr(out, "trace: Send a bounded active trace request") == out); - assert(strstr(out, "Usage: trace [hex-path]") != NULL); + assert_starts_with(out, "trace: Run link trace"); + assert_contains(out, "Usage: trace [path]"); result = run_command("!help tracer", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strstr(out, "tracer: Show the current packet route hashes") == out); - assert(strstr(out, "Usage: tracer") != NULL); + assert_starts_with(out, "tracer: Run link trace"); + assert_contains(out, "Usage: tracer [path]"); result = run_command("!help t", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strstr(out, "test: Return a short firmware bot self-test response") == out); + assert_starts_with(out, "test: Get test response with connection info"); result = run_command("!help weather", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strcmp(out, "weather is unavailable in firmware") == 0); + assert(strcmp(out, "No help for weather") == 0); result = run_command("!help prefix", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strstr(out, "prefix: Look up a local contact by public-key prefix") == out); - assert(strstr(out, "Usage: prefix ") != NULL); + assert_starts_with(out, "prefix: Look up a local contact by public-key prefix"); + assert_contains(out, "Usage: prefix "); result = run_command("!prefix 01020304", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strcmp(out, "Prefix lookup unavailable") == 0); + assert(strcmp(out, "Prefix lookup requires local contacts") == 0); + + result = run_command("!lookup 0102", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Prefix lookup requires local contacts") == 0); result = run_command("!help nope", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); @@ -613,15 +740,15 @@ static void test_command_outputs() { result = run_command("!weather", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strcmp(out, "weather is unavailable in firmware") == 0); + assert(strcmp(out, "Unknown command. Try help") == 0); result = run_command("!reboot now", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strcmp(out, "reboot is unavailable in firmware") == 0); + assert(strcmp(out, "Unknown command. Try help") == 0); result = run_command("!nodes", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strcmp(out, "nodes is unavailable in firmware") == 0); + assert(strcmp(out, "Unknown command. Try help") == 0); result = run_command("!version", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); @@ -629,8 +756,8 @@ static void test_command_outputs() { result = run_command("!stats", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strstr(out, "Bot seen 9 ok 5 sent 4 fail 1") != NULL); - assert(strstr(out, "rf rx/tx 77/44") != NULL); + assert(strstr(out, "Bot: 9 seen, 5 ok, 4 sent, 1 fail") != NULL); + assert(strstr(out, "RF: 77 rx, 44 tx, 2 err, q 3") != NULL); result = run_command("!magic8", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); @@ -642,28 +769,32 @@ static void test_command_outputs() { result = run_command("!trace", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strcmp(out, "Trace route unavailable") == 0); + assert(strcmp(out, "Trace needs a known route. Try path first.") == 0); result = run_command("!trace zz", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strcmp(out, "Usage: trace [hex-path]") == 0); + assert(strcmp(out, "Usage: trace [path]") == 0); + + result = run_command("!trace 01,7a,55", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Trace ready: 3-hop route") == 0); result = run_command("!tracer", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strcmp(out, "Tracer unavailable") == 0); + assert(strcmp(out, "Tracer needs a known route. Try path first.") == 0); result = run_command("!tracer 1234", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strcmp(out, "Usage: tracer") == 0); + assert(strcmp(out, "Tracer ready: 1-hop route") == 0); result = run_command("!channels", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strcmp(out, "Channels: #bot #testing emergency=#emergency public=Public (4 configured)") == 0); + assert(strcmp(out, "Channels: bot #bot | testing #testing | emergency #emergency | public Public (4 total)") == 0); result = run_command("!status", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strstr(out, "cm-bot up 1234s") != NULL); - assert(strstr(out, "batt 4180mV") != NULL); + assert(strstr(out, "cm-bot | up 0d 0h 20m") != NULL); + assert(strstr(out, "batt 4180mV 97%") != NULL); assert(strstr(out, "sent 4 fail 1") != NULL); result = run_command("!wat", out, sizeof(out)); @@ -684,97 +815,183 @@ static void test_path_command() { context.path_snr_quarters = 23; BotCommandResult result = BotCommands::executeCommand(command, context, out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strcmp(out, "Path 3h x 2B snr 5.75: 1234abcd0001") == 0); + assert(strcmp(out, "Path 3h@2B SNR 5.75 | 1234 -> abcd -> 0001") == 0); - assert(FirmwareBot::parseCommand("!tracer", 7, &command)); + strncpy(context.response_target, "alice", sizeof(context.response_target) - 1); result = BotCommands::executeCommand(command, context, out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strcmp(out, "Tracer 3h x 2B snr 5.75: 1234abcd0001") == 0); + assert(strcmp(out, "Path @[alice] 3h@2B SNR 5.75 | 1234 -> abcd -> 0001") == 0); + context.response_target[0] = 0; - assert(FirmwareBot::parseCommand("!tracer 1234", 12, &command)); + assert(FirmwareBot::parseCommand("!path 1234,abcd,0001", 20, &command)); result = BotCommands::executeCommand(command, context, out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strcmp(out, "Usage: tracer") == 0); + assert(strcmp(out, "Path 3h@2B | 1234 -> abcd -> 0001") == 0); + + assert(FirmwareBot::parseCommand("!path 1234abcd0001", 18, &command)); + result = BotCommands::executeCommand(command, context, out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Path 3h@2B | 1234 -> abcd -> 0001") == 0); + + assert(FirmwareBot::parseCommand("!path 12,34", 11, &command)); + result = BotCommands::executeCommand(command, context, out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Usage: path [path]") == 0); + + assert(FirmwareBot::parseCommand("!path 12345678,abcdef01", 23, &command)); + result = BotCommands::executeCommand(command, context, out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Usage: path [path]") == 0); + + context.path_hash_size = 0; + result = BotCommands::executeCommand(command, context, out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Path 2h@4B | 12345678 -> abcdef01") == 0); + context.path_hash_size = 2; assert(FirmwareBot::parseCommand("!path", 5, &command)); + context.path = path; + context.path_len = 0; + context.path_hash_count = 0; + context.path_hash_size = 2; + context.path_snr_quarters = 0; + result = BotCommands::executeCommand(command, context, out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Path direct zero-hop SNR 0.00") == 0); + context.path = NULL; result = BotCommands::executeCommand(command, context, out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strcmp(out, "Path unavailable") == 0); + assert(strcmp(out, "Path direct zero-hop SNR 0.00") == 0); + + strncpy(context.response_target, "alice", sizeof(context.response_target) - 1); + result = BotCommands::executeCommand(command, context, out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Path @[alice] direct zero-hop SNR 0.00") == 0); + context.response_target[0] = 0; assert(FirmwareBot::parseCommand("!tracer", 7, &command)); result = BotCommands::executeCommand(command, context, out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strcmp(out, "Tracer unavailable") == 0); + assert(strcmp(out, "Tracer ready: direct zero-hop route") == 0); + + context.path_hash_size = 0; + result = BotCommands::executeCommand(command, context, out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Tracer needs a known route. Try path first.") == 0); +} + +static void test_trace_result_format() { + char out[BOT_MAX_RESPONSE_LEN + 1]; + uint8_t snrs_1b[] = { 0x14, 0x0F, 0x06 }; + uint8_t hashes_1b[] = { 0x12, 0xab, 0x01 }; + size_t len = BotCommands::formatTraceResult(out, sizeof(out), NULL, 0xabcd1234UL, 1, snrs_1b, hashes_1b, 3, 18); + assert(len == strlen(out)); + assert(strcmp(out, "Trace abcd1234 3h tail 4.50 | 12@5.00 -> ab@3.75 -> 01@1.50") == 0); + + len = BotCommands::formatTraceResult(out, sizeof(out), "alice", 0xabcd1234UL, 1, snrs_1b, hashes_1b, 3, 18); + assert(strcmp(out, "Trace @[alice] abcd1234 3h tail 4.50 | 12@5.00 -> ab@3.75 -> 01@1.50") == 0); + + uint8_t snrs_2b[] = { 0x14, (uint8_t)(int8_t)-5 }; + uint8_t hashes_2b[] = { 0x12, 0x34, 0xab, 0xcd }; + len = BotCommands::formatTraceResult(out, sizeof(out), NULL, 0x10000001UL, 2, snrs_2b, hashes_2b, 2, -10); + assert(strcmp(out, "Trace 10000001 2h tail -2.50 | 1234@5.00 -> abcd@-1.25") == 0); + + len = BotCommands::formatTraceResult(out, sizeof(out), "bob", 0x5UL, 1, NULL, NULL, 0, 8); + assert(len > 0); + assert(strcmp(out, "Trace @[bob] 00000005 direct zero-hop tail 2.00") == 0); + + len = BotCommands::formatTraceResult(out, sizeof(out), "", 0x5UL, 1, NULL, NULL, 0, 8); + assert(strcmp(out, "Trace 00000005 direct zero-hop tail 2.00") == 0); + + char small[16]; + len = BotCommands::formatTraceResult(small, sizeof(small), NULL, 0x12345678UL, 1, snrs_1b, hashes_1b, 3, 18); + assert(len == strlen(small)); + assert(len <= sizeof(small) - 1); + assert(strncmp(small, "Trace 12345678", 14) == 0); } static void test_dice_command() { char out[BOT_MAX_RESPONSE_LEN + 1]; BotCommandResult result = run_command("!roll", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strncmp(out, "Rolled 1-100: ", 14) == 0); + assert_roll_range(out, "Roll 1-100: ", 1, 100); result = run_command("!roll 20", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strncmp(out, "Rolled 1-20: ", 13) == 0); + assert_roll_range(out, "Roll 1-20: ", 1, 20); + + result = run_command("!roll 10000", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert_roll_range(out, "Roll 1-10000: ", 1, 10000); result = run_command("!roll 5 10", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strncmp(out, "Rolled 5-10: ", 13) == 0); + assert_roll_range(out, "Roll 5-10: ", 5, 10); result = run_command("!roll 1 1", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strcmp(out, "Rolled 1-1: 1") == 0); + assert(strcmp(out, "Roll 1-1: 1") == 0); result = run_command("!roll 2d6", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strcmp(out, "Usage: roll [max|low high], range 1-1000") == 0); + assert(strcmp(out, "Invalid number. Use roll, roll X, or roll low high (max 10000)") == 0); result = run_command("!roll 0", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strncmp(out, "Usage: roll", 11) == 0); + assert_starts_with(out, "Invalid number."); result = run_command("!roll 10 1", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strncmp(out, "Usage: roll", 11) == 0); + assert_starts_with(out, "Invalid number."); - result = run_command("!roll 100000", out, sizeof(out)); + result = run_command("!roll 10001", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strncmp(out, "Usage: roll", 11) == 0); + assert_starts_with(out, "Invalid number."); result = run_command("!dice", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strncmp(out, "Dice d6: ", 9) == 0); + assert_dice_single_range(out, "Dice d6: ", 6); result = run_command("!dice d20", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strncmp(out, "Dice d20: ", 10) == 0); - - result = run_command("!dice 2d6", out, sizeof(out)); - assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strncmp(out, "Dice 2d6: ", 10) == 0); - assert(strchr(out, '+') != NULL); - assert(strchr(out, '=') != NULL); + assert_dice_single_range(out, "Dice d20: ", 20); result = run_command("!dice 20", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strcmp(out, "Usage: dice [dN|NdN], max 10 dice, sides 2-1000") == 0); + assert_dice_single_range(out, "Dice d20: ", 20); + + result = run_command("!dice 2d6", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert_starts_with(out, "Dice 2d6: ["); + assert(strchr(out, ',') != NULL); + assert(strchr(out, '=') != NULL); + + result = run_command("!dice d10 d6", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert_starts_with(out, "Dice d10: "); + assert_contains(out, " + d6: "); + assert_contains(out, " | Total: "); + + result = run_command("!dice decade", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert_starts_with(out, "Dice decade: "); result = run_command("!dice d1", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strncmp(out, "Usage: dice", 11) == 0); + assert_starts_with(out, "Invalid dice type."); result = run_command("!dice 0d6", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strncmp(out, "Usage: dice", 11) == 0); + assert_starts_with(out, "Invalid dice type."); result = run_command("!dice 11d6", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strncmp(out, "Usage: dice", 11) == 0); + assert_starts_with(out, "Invalid dice type."); result = run_command("!dice 2d1001", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strncmp(out, "Usage: dice", 11) == 0); + assert_starts_with(out, "Invalid dice type."); } static void test_command_truncation() { @@ -801,6 +1018,12 @@ static void test_command_cooldown() { FirmwareBot::recordCommandCooldown(cooldowns, 2, BOT_COMMAND_STATUS, 0xFFFFFFF0UL, 32); assert(FirmwareBot::isCommandOnCooldown(cooldowns, 2, BOT_COMMAND_STATUS, 0xFFFFFFF8UL)); assert(!FirmwareBot::isCommandOnCooldown(cooldowns, 2, BOT_COMMAND_STATUS, 0x00000010UL)); + + memset(cooldowns, 0, sizeof(cooldowns)); + FirmwareBot::recordCommandCooldown(cooldowns, 2, BOT_COMMAND_TRACER, 2000, 5000); + assert(FirmwareBot::isCommandOnCooldown(cooldowns, 2, BOT_COMMAND_TRACE, 6999)); + assert(FirmwareBot::isCommandOnCooldown(cooldowns, 2, BOT_COMMAND_TRACER, 6999)); + assert(!FirmwareBot::isCommandOnCooldown(cooldowns, 2, BOT_COMMAND_TRACE, 7000)); } static void test_group_response_cap() { @@ -817,7 +1040,7 @@ static void test_emergency_forward_short() { assert(EmergencyForwarder::format(message, forward)); assert(forward.part_count == 1); assert(!forward.truncated); - assert(strstr(forward.parts[0], "EMERGENCY MESSAGE FROM alice: need help at trailhead") == forward.parts[0]); + assert(strstr(forward.parts[0], "EMERGENCY MESSAGE FROM @[alice]: need help at trailhead") == forward.parts[0]); assert(forward.part_lens[0] == strlen(forward.parts[0])); } @@ -845,7 +1068,7 @@ static void test_emergency_forward_multipart() { assert(forward.part_count <= BOT_EMERGENCY_MAX_PARTS); assert(!forward.truncated); for (uint8_t i = 0; i < forward.part_count; i++) { - assert(strstr(forward.parts[i], "EMERGENCY MESSAGE FROM alice: ") == forward.parts[i]); + assert(strstr(forward.parts[i], "EMERGENCY MESSAGE FROM @[alice]: ") == forward.parts[i]); assert(strstr(forward.parts[i], "[") != NULL); assert(forward.part_lens[i] <= BOT_MAX_GROUP_RESPONSE_LEN); } @@ -1094,10 +1317,10 @@ static void test_response_coordinator_suppression_uses_response_fingerprint() { static void test_trace_final_response_fingerprints_use_final_text() { BotMessage message = make_message("#bot", "trace"); - BotFingerprint sent = final_response_fingerprint_for(message, "Trace sent"); - BotFingerprint result_a = final_response_fingerprint_for(message, "Trace 12345678 2h x 2B snr 1.25: 11112222"); - BotFingerprint result_b = final_response_fingerprint_for(message, "Trace 12345678 2h x 2B snr 1.25: aaaabbbb"); - BotFingerprint timeout = final_response_fingerprint_for(message, "Trace timed out"); + BotFingerprint sent = final_response_fingerprint_for(message, "Trace sent on 2-hop route"); + BotFingerprint result_a = final_response_fingerprint_for(message, "Trace 12345678 2h tail 1.25 | 1111@1.00 -> 2222@1.25"); + BotFingerprint result_b = final_response_fingerprint_for(message, "Trace 12345678 2h tail 1.25 | aaaa@1.00 -> bbbb@1.25"); + BotFingerprint timeout = final_response_fingerprint_for(message, "Trace 12345678 timed out, no reply on 2-hop route"); assert(sent.value != result_a.value); assert(result_a.value != result_b.value); assert(timeout.value != sent.value); @@ -1120,8 +1343,10 @@ static void test_response_coordinator_distinct_path_outputs() { second.path_hash_count = 2; BotFingerprint first_request = FirmwareBot::fingerprintFor(first); BotFingerprint second_request = FirmwareBot::fingerprintFor(second); - BotFingerprint first_response = FirmwareBot::responseFingerprintFor(first, "Path 2h x 2B snr 0.00: 12345678", 34); - BotFingerprint second_response = FirmwareBot::responseFingerprintFor(second, "Path 2h x 2B snr 0.00: abcdef01", 34); + const char* first_path_response = "Path 2h@2B SNR 0.00 | 1234 -> 5678"; + const char* second_path_response = "Path 2h@2B SNR 0.00 | abcd -> ef01"; + BotFingerprint first_response = FirmwareBot::responseFingerprintFor(first, first_path_response, strlen(first_path_response)); + BotFingerprint second_response = FirmwareBot::responseFingerprintFor(second, second_path_response, strlen(second_path_response)); BotFingerprint scheduled; uint32_t first_due = 0; uint32_t second_due = 0; @@ -1169,14 +1394,14 @@ static void test_response_coordinator_group_guarded_path_output_suppresses() { ResponseCoordinator::clear(pending, 1); BotMessage request_message = make_message("#bot", "path"); BotFingerprint request = FirmwareBot::fingerprintFor(request_message); - const char* path_text = "Path 2h x 2B snr 0.00: 12345678"; + const char* path_text = "Path 2h@2B SNR 0.00 | 1234 -> 5678"; char final_response[BOT_MAX_RESPONSE_LEN + 1]; size_t final_response_len = 0; assert(FirmwareBot::writeResponseForChannel(request_message.channel_kind, BotPolicy::isPrefixlessCommandAllowed(request_message.channel_kind), path_text, strlen(path_text), final_response, sizeof(final_response), &final_response_len) == BOT_WRITE_OK); - assert(strcmp(final_response, "# Path 2h x 2B snr 0.00: 12345678") == 0); + assert(strcmp(final_response, "# Path 2h@2B SNR 0.00 | 1234 -> 5678") == 0); BotCommand command; assert(!FirmwareBot::parseCommand(final_response, final_response_len, &command, true)); BotFingerprint response = FirmwareBot::responseFingerprintFor(request_message, final_response, final_response_len); @@ -1203,7 +1428,8 @@ static void test_response_coordinator_suppress_expire_full() { BotFingerprint first_request = FirmwareBot::fingerprintFor(message); BotFingerprint second_request = FirmwareBot::fingerprintFor(second_message); BotFingerprint first_response = FirmwareBot::responseFingerprintFor(message, "Pong!", 5); - BotFingerprint second_response = FirmwareBot::responseFingerprintFor(second_message, "Bot test OK", 11); + const char* ack_response = "ack @[alice] | direct | Received at: 21:25:45"; + BotFingerprint second_response = FirmwareBot::responseFingerprintFor(second_message, ack_response, strlen(ack_response)); BotFingerprint scheduled; uint32_t due = 0; uint32_t identity_seed = 0x11223344UL; @@ -1268,6 +1494,26 @@ static void test_response_coordinator_cancel_by_request() { assert(ready.response_fingerprint.value == response.value); } +static void test_response_coordinator_schedule_preserves_suppression() { + BotCoordinatorPending pending[1]; + ResponseCoordinator::clear(pending, 1); + BotMessage message = make_message("#bot", "!ping"); + BotFingerprint request = FirmwareBot::fingerprintFor(message); + BotFingerprint response = FirmwareBot::responseFingerprintFor(message, "Pong!", 5); + BotFingerprint scheduled; + uint32_t due = 0; + + assert(ResponseCoordinator::schedule(pending, 1, message, BOT_COMMAND_PING, request, response, 1000, 0, + 0x01020304UL, 0, &scheduled, &due) == BOT_COORDINATOR_SCHEDULED); + assert(ResponseCoordinator::suppress(pending, 1, response)); + assert(pending[0].suppressed); + assert(ResponseCoordinator::schedule(pending, 1, message, BOT_COMMAND_PING, request, response, 1500, 0, + 0x01020304UL, 0, &scheduled, &due) == BOT_COORDINATOR_REPLACED); + assert(pending[0].suppressed); + BotCoordinatorReady ready = ResponseCoordinator::poll(pending, 1, 1500); + assert(ready.result == BOT_COORDINATOR_READY_SUPPRESSED); +} + static void test_response_coordinator_delay_biases() { BotMessage message = make_message("#bot", "!ping"); BotFingerprint request = { 0x0123456789ABCDEFULL }; @@ -1278,14 +1524,88 @@ static void test_response_coordinator_delay_biases() { uint32_t trace = ResponseCoordinator::responseDelayMillis(message, BOT_COMMAND_TRACE, request, 0x01020304UL, 0, 0); uint32_t tracer = ResponseCoordinator::responseDelayMillis(message, BOT_COMMAND_TRACER, request, 0x01020304UL, 0, 0); assert(first != second); - assert(roll == dice); - assert(roll == first + 200); - assert(trace == tracer); - assert(trace == first + 800); + assert(roll == first); + assert(dice == first); + assert(trace == first); + assert(tracer == first); assert(ResponseCoordinator::responseDelayMillis(message, BOT_COMMAND_PING, request, 0x01020304UL, 2, BOT_RESPONSE_DELAY_JITTER_MILLIS + 17) == first + 300 + 17); } +static void test_response_coordinator_hop_count_ranking() { + BotMessage near = make_message("#bot", "!ping"); + near.path_hash_count = 0; + BotMessage one_hop = near; + one_hop.path_hash_count = 1; + BotMessage two_hop = near; + two_hop.path_hash_count = 2; + BotFingerprint request = { 0x0123456789ABCDEFULL }; + uint32_t base_delay = BOT_RESPONSE_DELAY_BASE_MILLIS; + uint32_t jitter = BOT_RESPONSE_DELAY_JITTER_MILLIS; + uint16_t hop_step = 1500; + uint32_t d0 = ResponseCoordinator::responseDelayMillis(near, BOT_COMMAND_PING, request, 0x01020304UL, 0, 0, + base_delay, jitter, hop_step); + uint32_t d1 = ResponseCoordinator::responseDelayMillis(one_hop, BOT_COMMAND_PING, request, 0x01020304UL, 0, 0, + base_delay, jitter, hop_step); + uint32_t d2 = ResponseCoordinator::responseDelayMillis(two_hop, BOT_COMMAND_PING, request, 0x01020304UL, 0, 0, + base_delay, jitter, hop_step); + assert(d1 == d0 + hop_step); + assert(d2 == d0 + (uint32_t)hop_step * 2); + + uint32_t alt_step = 2000; + uint32_t d0_alt = ResponseCoordinator::responseDelayMillis(near, BOT_COMMAND_PING, request, 0x01020304UL, 0, 0, + base_delay, jitter, alt_step); + uint32_t d1_alt = ResponseCoordinator::responseDelayMillis(one_hop, BOT_COMMAND_PING, request, 0x01020304UL, 0, 0, + base_delay, jitter, alt_step); + assert(d0_alt == d0); + assert(d1_alt == d0 + alt_step); + + BotMessage dm = make_message("dm", "!ping"); + dm.channel_kind = BOT_CHANNEL_DM; + dm.path_hash_count = 3; + uint32_t dm_delay = ResponseCoordinator::responseDelayMillis(dm, BOT_COMMAND_PING, request, 0x01020304UL, 0, 0, + base_delay, jitter, hop_step); + BotMessage dm_zero = dm; + dm_zero.path_hash_count = 0; + uint32_t dm_zero_delay = ResponseCoordinator::responseDelayMillis(dm_zero, BOT_COMMAND_PING, request, 0x01020304UL, + 0, 0, base_delay, jitter, hop_step); + assert(dm_delay == dm_zero_delay); +} + +static void test_response_coordinator_hop_bias_cap() { + BotMessage near = make_message("#bot", "!ping"); + near.path_hash_count = 0; + BotMessage many_hops = near; + many_hops.path_hash_count = 10; + BotFingerprint request = { 0x0123456789ABCDEFULL }; + uint32_t base_delay = BOT_RESPONSE_DELAY_BASE_MILLIS; + uint32_t jitter = BOT_RESPONSE_DELAY_JITTER_MILLIS; + uint16_t hop_step = 1500; + uint32_t d0 = ResponseCoordinator::responseDelayMillis(near, BOT_COMMAND_PING, request, 0x01020304UL, 0, 0, + base_delay, jitter, hop_step); + uint32_t dmany = ResponseCoordinator::responseDelayMillis(many_hops, BOT_COMMAND_PING, request, 0x01020304UL, 0, 0, + base_delay, jitter, hop_step); + assert(dmany == d0 + BOT_HOP_BIAS_MAX_MILLIS); + assert((uint32_t)many_hops.path_hash_count * hop_step > BOT_HOP_BIAS_MAX_MILLIS); + assert(dmany + BOT_RESPONSE_DELAY_JITTER_MILLIS < BOT_RESPONSE_PENDING_TTL_MILLIS); +} + +static void test_response_coordinator_same_hop_tiebreak() { + BotMessage message = make_message("#bot", "!ping"); + message.path_hash_count = 2; + BotFingerprint request = { 0x0123456789ABCDEFULL }; + uint16_t hop_step = 1500; + uint32_t bot_a = ResponseCoordinator::responseDelayMillis(message, BOT_COMMAND_PING, request, 0x01020304UL, 0, 0, + BOT_RESPONSE_DELAY_BASE_MILLIS, + BOT_RESPONSE_DELAY_JITTER_MILLIS, hop_step); + uint32_t bot_b = ResponseCoordinator::responseDelayMillis(message, BOT_COMMAND_PING, request, 0xFEDCBA98UL, 0, 0, + BOT_RESPONSE_DELAY_BASE_MILLIS, + BOT_RESPONSE_DELAY_JITTER_MILLIS, hop_step); + assert(bot_a != bot_b); + uint32_t spread = bot_a > bot_b ? bot_a - bot_b : bot_b - bot_a; + assert(spread < 900); +} + static void test_response_coordinator_recent_responses() { BotCoordinatorRecent recent[1]; ResponseCoordinator::clearRecent(recent, 1); @@ -1340,9 +1660,11 @@ int main() { test_parse_command(); test_response_write(); test_channel_text_and_response_guards(); + test_ack_response_format(); test_bot_advert_marker(); test_command_outputs(); test_path_command(); + test_trace_result_format(); test_dice_command(); test_command_truncation(); test_command_cooldown(); @@ -1361,11 +1683,15 @@ int main() { test_response_coordinator_suppression_uses_response_fingerprint(); test_trace_final_response_fingerprints_use_final_text(); test_response_coordinator_distinct_path_outputs(); + test_response_coordinator_schedule_preserves_suppression(); test_response_coordinator_group_observed_response_suppresses(); test_response_coordinator_group_guarded_path_output_suppresses(); test_response_coordinator_suppress_expire_full(); test_response_coordinator_cancel_by_request(); test_response_coordinator_delay_biases(); + test_response_coordinator_hop_count_ranking(); + test_response_coordinator_hop_bias_cap(); + test_response_coordinator_same_hop_tiebreak(); test_response_coordinator_recent_responses(); test_response_coordinator_rejects_non_normal(); printf("firmware_bot tests passed\n"); diff --git a/vendor/MeshCore b/vendor/MeshCore index 1c7349f..d2ad3ac 160000 --- a/vendor/MeshCore +++ b/vendor/MeshCore @@ -1 +1 @@ -Subproject commit 1c7349f91a78408d1b68de7e56e15910a00b0718 +Subproject commit d2ad3ac818e01c94997c51d587a7911cc6a8cfee