diff --git a/patches/meshcore/0008-Allow-prefixless-normal-bot-commands.patch b/patches/meshcore/0008-Allow-prefixless-normal-bot-commands.patch new file mode 100644 index 0000000..f3b14b1 --- /dev/null +++ b/patches/meshcore/0008-Allow-prefixless-normal-bot-commands.patch @@ -0,0 +1,64 @@ +diff --git a/examples/companion_radio/FirmwareBot.cpp b/examples/companion_radio/FirmwareBot.cpp +index 56a3395..de44fb9 100644 +--- a/examples/companion_radio/FirmwareBot.cpp ++++ b/examples/companion_radio/FirmwareBot.cpp +@@ -163,6 +163,10 @@ void recordCommandCooldown(BotCommandCooldown* cooldowns, size_t cooldown_count, + } + + bool parseCommand(const char* text, size_t text_len, BotCommand* command) { ++ return parseCommand(text, text_len, command, false); ++} ++ ++bool parseCommand(const char* text, size_t text_len, BotCommand* command, bool allow_prefixless) { + if (!command) return false; + memset(command, 0, sizeof(*command)); + command->id = BOT_COMMAND_NONE; +@@ -171,9 +175,13 @@ bool parseCommand(const char* text, size_t text_len, BotCommand* command) { + size_t normalized_len = 0; + normalizeText(text, text_len, normalized, sizeof(normalized), &normalized_len); + +- if (normalized_len < 2 || (normalized[0] != '!' && normalized[0] != '/')) return false; ++ if (normalized_len == 0) return false; ++ ++ bool has_prefix = normalized[0] == '!' || normalized[0] == '/'; ++ if (!has_prefix && !allow_prefixless) return false; ++ if (has_prefix && normalized_len < 2) return false; + +- size_t pos = 1; ++ size_t pos = has_prefix ? 1 : 0; + while (pos < normalized_len && normalized[pos] == ' ') pos++; + size_t name_start = pos; + while (pos < normalized_len && !isCommandDelimiter(normalized[pos])) pos++; +@@ -187,6 +195,7 @@ bool parseCommand(const char* text, size_t text_len, BotCommand* command) { + } + command->name[copy_name_len] = 0; + command->id = name_len > BOT_MAX_COMMAND_NAME_LEN ? BOT_COMMAND_UNKNOWN : commandIdForName(command->name, copy_name_len); ++ if (!has_prefix && command->id == BOT_COMMAND_UNKNOWN) return false; + + while (pos < normalized_len && isCommandDelimiter(normalized[pos])) pos++; + size_t args_len = normalized_len - pos; +diff --git a/examples/companion_radio/FirmwareBot.h b/examples/companion_radio/FirmwareBot.h +index 42b9c5b..e248adc 100644 +--- a/examples/companion_radio/FirmwareBot.h ++++ b/examples/companion_radio/FirmwareBot.h +@@ -6,6 +6,7 @@ namespace FirmwareBot { + + BotWriteResult normalizeText(const char* input, size_t input_len, char* output, size_t output_len, size_t* written); + bool parseCommand(const char* text, size_t text_len, BotCommand* command); ++bool parseCommand(const char* text, size_t text_len, BotCommand* command, bool allow_prefixless); + bool splitChannelText(const char* text, size_t text_len, char* sender, size_t sender_len, const char** body, + size_t* body_len); + BotWriteResult writeResponse(char* output, size_t output_len, const char* text, size_t text_len, size_t* written); +diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp +index 13ead48..ec6a780 100644 +--- a/examples/companion_radio/MyMesh.cpp ++++ b/examples/companion_radio/MyMesh.cpp +@@ -1011,7 +1011,7 @@ void MyMesh::recordBotObservation(const BotMessage &message, const ContactInfo * + sendQueuedBotResponses(); + + BotCommand command; +- if (!FirmwareBot::parseCommand(message.text, message.text_len, &command)) { ++ if (!FirmwareBot::parseCommand(message.text, message.text_len, &command, true)) { + if (message.text_len > 0 && (message.text[0] == '!' || message.text[0] == '/')) bot_stats.parse_errors++; + return; + } diff --git a/patches/meshcore/0009-Add-firmware-bot-utility-commands.patch b/patches/meshcore/0009-Add-firmware-bot-utility-commands.patch new file mode 100644 index 0000000..9cb7427 --- /dev/null +++ b/patches/meshcore/0009-Add-firmware-bot-utility-commands.patch @@ -0,0 +1,410 @@ +diff --git a/examples/companion_radio/BotCommands.cpp b/examples/companion_radio/BotCommands.cpp +index 2c206a9..6222be5 100644 +--- a/examples/companion_radio/BotCommands.cpp ++++ b/examples/companion_radio/BotCommands.cpp +@@ -62,7 +62,7 @@ bool parseUInt(const char* text, size_t len, size_t* pos, uint16_t* value) { + } + + bool isSupportedSides(uint16_t sides) { +- return sides == 4 || sides == 6 || sides == 8 || sides == 10 || sides == 12 || sides == 16 || sides == 20; ++ return sides >= 2 && sides <= 1000; + } + + bool parseDice(const BotCommand& command, uint16_t* count, uint16_t* sides) { +@@ -97,11 +97,57 @@ uint16_t rollOnce(uint32_t* state, uint16_t sides) { + return (uint16_t)((*state >> 16) % sides) + 1; + } + ++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++) { ++ if (*pos + 2 < output_len) { ++ output[*pos] = hex[path[i] >> 4]; ++ output[*pos + 1] = hex[path[i] & 0x0F]; ++ } ++ *pos += 2; ++ } ++ if (output_len > 0) output[*pos < output_len ? *pos : output_len - 1] = 0; ++} ++ ++void formatQuarters(int8_t quarters, char* output, size_t output_len) { ++ if (!output || output_len == 0) return; ++ int value = quarters; ++ const char* sign = value < 0 ? "-" : ""; ++ if (value < 0) value = -value; ++ snprintf(output, output_len, "%s%d.%02d", sign, value / 4, (value % 4) * 25); ++} ++ ++BotCommandResult executeMagic8(const BotCommandContext& context, char* output, size_t output_len) { ++ static const char* responses[] = { ++ "It is certain", "Looks good", "Ask again later", "Cannot predict now", "Doubtful", "Very likely", ++ "Signs point yes", "No", "Reply hazy", "Absolutely" ++ }; ++ uint32_t seed = context.random_seed ? context.random_seed : 1; ++ seed = seed * 1664525UL + 1013904223UL; ++ return writeFormatted(output, output_len, "Magic 8-ball: %s", responses[(seed >> 16) % (sizeof(responses) / sizeof(responses[0]))]); ++} ++ ++BotCommandResult executePath(const BotCommandContext& context, char* output, size_t output_len) { ++ if (!context.path || context.path_len == 0 || context.path_hash_count == 0) return writeText(output, output_len, "Path unavailable"); ++ 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, "Path %uh x %uB snr %s: ", (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; ++ appendPathHex(output, output_len, &pos, context.path, byte_len); ++ size_t actual = boundedStrLen(output, output_len); ++ return makeResult(pos >= output_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, actual); ++} ++ + 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: !roll [d6|d20|2d6], sides: d4 d6 d8 d10 d12 d16 d20"); ++ return writeText(output, output_len, "Usage: roll [N|dN|NdN], max 10 dice, sides 2-1000"); + } + + uint32_t state = context.random_seed ^ ((uint32_t)count << 16) ^ sides; +@@ -141,7 +187,7 @@ BotCommandResult executeCommand(const BotCommand& command, const BotCommandConte + size_t output_len) { + switch (command.id) { + case BOT_COMMAND_HELP: +- return writeText(output, output_len, "Commands: !ping !test !hello !about !roll [d20|2d6] !status !channels"); ++ return writeText(output, output_len, "Commands: ping t hello about roll stats version path magic8 status channels"); + case BOT_COMMAND_PING: + return writeText(output, output_len, "Pong!"); + case BOT_COMMAND_TEST: +@@ -164,8 +210,22 @@ BotCommandResult executeCommand(const BotCommand& command, const BotCommandConte + context.testing_channel[0] ? context.testing_channel : "#testing", + context.emergency_channel[0] ? context.emergency_channel : "#emergency", + context.public_channel[0] ? context.public_channel : "Public", (unsigned)context.channel_count); ++ case BOT_COMMAND_VERSION: ++ 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", ++ (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: ++ return executeMagic8(context, output, output_len); ++ case BOT_COMMAND_PATH: ++ return executePath(context, output, output_len); + case BOT_COMMAND_UNKNOWN: +- return writeText(output, output_len, "Unknown command. Try !help"); ++ return writeText(output, output_len, "Unknown command. Try help"); + default: + return makeResult(BOT_COMMAND_RESULT_NOT_HANDLED, 0); + } +diff --git a/examples/companion_radio/BotPrefs.cpp b/examples/companion_radio/BotPrefs.cpp +index 1d0810a..0eff075 100644 +--- a/examples/companion_radio/BotPrefs.cpp ++++ b/examples/companion_radio/BotPrefs.cpp +@@ -294,27 +294,51 @@ const char* commandName(BotCommandId command_id) { + case BOT_COMMAND_DICE: return "roll"; + case BOT_COMMAND_STATUS: return "status"; + case BOT_COMMAND_CHANNELS: return "channels"; ++ case BOT_COMMAND_VERSION: return "version"; ++ case BOT_COMMAND_STATS: return "stats"; ++ case BOT_COMMAND_MAGIC8: return "magic8"; ++ case BOT_COMMAND_PATH: return "path"; + case BOT_COMMAND_UNKNOWN: return "unknown"; + default: return ""; + } + } + ++bool namesEqual(const char* name, size_t len, const char* expected) { ++ size_t expected_len = boundedStrLen(expected, BOT_MAX_COMMAND_NAME_LEN + 1); ++ if (len != expected_len) return false; ++ for (size_t i = 0; i < len; i++) { ++ if (tolower((unsigned char)name[i]) != tolower((unsigned char)expected[i])) return false; ++ } ++ return true; ++} ++ + bool commandIdForName(const char* name, BotCommandId* command_id) { + if (!name || !command_id) return false; ++ size_t len = boundedStrLen(name, BOT_MAX_COMMAND_NAME_LEN + 1); ++ if (namesEqual(name, len, "t")) { ++ *command_id = BOT_COMMAND_TEST; ++ return true; ++ } ++ if (namesEqual(name, len, "dice")) { ++ *command_id = BOT_COMMAND_DICE; ++ return true; ++ } ++ if (namesEqual(name, len, "ver")) { ++ *command_id = BOT_COMMAND_VERSION; ++ return true; ++ } ++ if (namesEqual(name, len, "8ball") || namesEqual(name, len, "eightball")) { ++ *command_id = BOT_COMMAND_MAGIC8; ++ return true; ++ } ++ if (namesEqual(name, len, "p") || namesEqual(name, len, "decode") || namesEqual(name, len, "route")) { ++ *command_id = BOT_COMMAND_PATH; ++ return true; ++ } + for (uint8_t id = BOT_COMMAND_HELP; id <= BOT_COMMAND_UNKNOWN; id++) { + const char* candidate = commandName((BotCommandId)id); + if (candidate[0] == 0) continue; +- size_t len = boundedStrLen(name, BOT_MAX_COMMAND_NAME_LEN + 1); +- size_t candidate_len = boundedStrLen(candidate, BOT_MAX_COMMAND_NAME_LEN + 1); +- if (len != candidate_len) continue; +- bool match = true; +- for (size_t i = 0; i < len; i++) { +- if (tolower((unsigned char)name[i]) != tolower((unsigned char)candidate[i])) { +- match = false; +- break; +- } +- } +- if (match) { ++ if (namesEqual(name, len, candidate)) { + *command_id = (BotCommandId)id; + return true; + } +diff --git a/examples/companion_radio/BotTypes.h b/examples/companion_radio/BotTypes.h +index 98d4a58..5eb71c6 100644 +--- a/examples/companion_radio/BotTypes.h ++++ b/examples/companion_radio/BotTypes.h +@@ -9,6 +9,9 @@ + #define BOT_MAX_COMMAND_ARGS_LEN 79 + #define BOT_MAX_CHANNEL_NAME_LEN 23 + #define BOT_MAX_SENDER_NAME_LEN 31 ++#define BOT_MAX_FIRMWARE_VERSION_LEN 19 ++#define BOT_MAX_BUILD_DATE_LEN 15 ++#define BOT_MAX_PATH_BYTES 64 + #define BOT_GROUP_RESPONSE_PREFIX_RESERVE (BOT_MAX_SENDER_NAME_LEN + 2) + #define BOT_MAX_GROUP_RESPONSE_LEN (BOT_MAX_TEXT_LEN - BOT_GROUP_RESPONSE_PREFIX_RESERVE) + #define BOT_COMMAND_COOLDOWN_MILLIS 5000UL +@@ -43,10 +46,16 @@ + #define BOT_COMMAND_MASK_DICE (1UL << BOT_COMMAND_DICE) + #define BOT_COMMAND_MASK_STATUS (1UL << BOT_COMMAND_STATUS) + #define BOT_COMMAND_MASK_CHANNELS (1UL << BOT_COMMAND_CHANNELS) ++#define BOT_COMMAND_MASK_VERSION (1UL << BOT_COMMAND_VERSION) ++#define BOT_COMMAND_MASK_STATS (1UL << BOT_COMMAND_STATS) ++#define BOT_COMMAND_MASK_MAGIC8 (1UL << BOT_COMMAND_MAGIC8) ++#define BOT_COMMAND_MASK_PATH (1UL << BOT_COMMAND_PATH) + #define BOT_COMMAND_MASK_UNKNOWN (1UL << BOT_COMMAND_UNKNOWN) + #define BOT_COMMAND_MASK_ALL (BOT_COMMAND_MASK_HELP | BOT_COMMAND_MASK_PING | BOT_COMMAND_MASK_TEST | \ + BOT_COMMAND_MASK_HELLO | BOT_COMMAND_MASK_ABOUT | BOT_COMMAND_MASK_DICE | \ +- BOT_COMMAND_MASK_STATUS | BOT_COMMAND_MASK_CHANNELS | BOT_COMMAND_MASK_UNKNOWN) ++ 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_UNKNOWN) + #define BOT_PREFS_SERIALIZED_SIZE 294 + + enum BotChannelKind : uint8_t { +@@ -74,6 +83,10 @@ enum BotCommandId : uint8_t { + BOT_COMMAND_DICE, + BOT_COMMAND_STATUS, + BOT_COMMAND_CHANNELS, ++ BOT_COMMAND_VERSION, ++ BOT_COMMAND_STATS, ++ BOT_COMMAND_MAGIC8, ++ BOT_COMMAND_PATH, + BOT_COMMAND_UNKNOWN + }; + +@@ -116,8 +129,13 @@ struct BotMessage { + uint8_t sender_key_prefix_len; + bool text_truncated; + uint32_t sender_timestamp; ++ uint8_t path_len; ++ uint8_t path_hash_size; ++ uint8_t path_hash_count; ++ int8_t packet_snr_quarters; + char text[BOT_MAX_TEXT_LEN + 1]; + size_t text_len; ++ const uint8_t* path; + }; + + struct BotCommand { +@@ -141,6 +159,8 @@ struct BotCommandContext { + char testing_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; + char emergency_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; + char public_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; ++ char firmware_version[BOT_MAX_FIRMWARE_VERSION_LEN + 1]; ++ char firmware_build_date[BOT_MAX_BUILD_DATE_LEN + 1]; + uint32_t uptime_seconds; + uint16_t battery_millivolts; + uint32_t storage_used_kb; +@@ -150,8 +170,30 @@ struct BotCommandContext { + uint32_t eligible_messages; + uint32_t sent_messages; + uint32_t send_failures; ++ uint32_t suppressed_responses; ++ uint32_t pending_responses; ++ uint32_t emergency_forwards; ++ uint32_t emergency_forward_failures; ++ uint32_t packets_recv; ++ uint32_t packets_sent; ++ uint32_t packets_recv_errors; ++ uint32_t flood_recv; ++ uint32_t flood_sent; ++ uint32_t direct_recv; ++ uint32_t direct_sent; ++ uint32_t tx_airtime_seconds; ++ uint32_t rx_airtime_seconds; + uint32_t random_seed; ++ int16_t noise_floor; ++ int8_t last_rssi; ++ int8_t last_snr_quarters; ++ uint8_t queue_depth; + uint8_t channel_count; ++ uint8_t path_len; ++ uint8_t path_hash_size; ++ uint8_t path_hash_count; ++ int8_t path_snr_quarters; ++ const uint8_t* path; + }; + + struct BotCommandResult { +@@ -232,10 +274,10 @@ struct BotStats { + uint32_t send_failures; + }; + +-static_assert(sizeof(BotMessage) <= 248, "BotMessage RAM budget exceeded"); ++static_assert(sizeof(BotMessage) <= 264, "BotMessage RAM budget exceeded"); + static_assert(sizeof(BotCommand) <= 120, "BotCommand RAM budget exceeded"); + static_assert(sizeof(BotResponse) <= 184, "BotResponse RAM budget exceeded"); +-static_assert(sizeof(BotCommandContext) <= 192, "BotCommandContext RAM budget exceeded"); ++static_assert(sizeof(BotCommandContext) <= 312, "BotCommandContext RAM budget exceeded"); + 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"); +diff --git a/examples/companion_radio/FirmwareBot.cpp b/examples/companion_radio/FirmwareBot.cpp +index de44fb9..8cdf764 100644 +--- a/examples/companion_radio/FirmwareBot.cpp ++++ b/examples/companion_radio/FirmwareBot.cpp +@@ -119,12 +119,16 @@ BotWriteResult normalizeText(const char* input, size_t input_len, char* output, + BotCommandId commandIdForName(const char* name, size_t len) { + if (namesEqual(name, len, "help") || namesEqual(name, len, "cmd") || namesEqual(name, len, "commands")) return BOT_COMMAND_HELP; + if (namesEqual(name, len, "ping")) return BOT_COMMAND_PING; +- if (namesEqual(name, len, "test")) return BOT_COMMAND_TEST; ++ if (namesEqual(name, len, "test") || namesEqual(name, len, "t")) return BOT_COMMAND_TEST; + if (namesEqual(name, len, "hello") || namesEqual(name, len, "hi")) return BOT_COMMAND_HELLO; + if (namesEqual(name, len, "about")) return BOT_COMMAND_ABOUT; + if (namesEqual(name, len, "dice") || namesEqual(name, len, "roll")) return BOT_COMMAND_DICE; + if (namesEqual(name, len, "status")) return BOT_COMMAND_STATUS; + if (namesEqual(name, len, "channels")) return BOT_COMMAND_CHANNELS; ++ if (namesEqual(name, len, "version") || namesEqual(name, len, "ver")) return BOT_COMMAND_VERSION; ++ if (namesEqual(name, len, "stats")) return BOT_COMMAND_STATS; ++ if (namesEqual(name, len, "magic8") || namesEqual(name, len, "8ball") || namesEqual(name, len, "eightball")) return BOT_COMMAND_MAGIC8; ++ if (namesEqual(name, len, "path") || namesEqual(name, len, "p") || namesEqual(name, len, "decode") || namesEqual(name, len, "route")) return BOT_COMMAND_PATH; + return BOT_COMMAND_UNKNOWN; + } + +diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp +index ec6a780..895be4b 100644 +--- a/examples/companion_radio/MyMesh.cpp ++++ b/examples/companion_radio/MyMesh.cpp +@@ -835,7 +835,7 @@ void MyMesh::observeBotDirectMessage(const ContactInfo &from, uint32_t sender_ti + } + + void MyMesh::observeBotChannelMessage(uint8_t channel_idx, const char *channel_name, const char *text, +- uint32_t sender_timestamp) { ++ uint32_t sender_timestamp, const mesh::Packet *packet) { + BotMessage message; + memset(&message, 0, sizeof(message)); + size_t channel_len = botBoundedStrLen(channel_name, BOT_MAX_CHANNEL_NAME_LEN); +@@ -845,6 +845,13 @@ void MyMesh::observeBotChannelMessage(uint8_t channel_idx, const char *channel_n + message.channel_name[channel_len] = 0; + } + message.sender_timestamp = sender_timestamp; ++ 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(); ++ message.path_hash_count = packet->getPathHashCount(); ++ message.packet_snr_quarters = (int8_t)(packet->getSNR() * 4); ++ message.path = packet->path; ++ } + + const char *body = text; + size_t body_len = botBoundedStrLen(text, BOT_MAX_TEXT_LEN); +@@ -867,10 +874,14 @@ void MyMesh::buildBotCommandContext(BotCommandContext &context, BotCommandId com + context.eligible_messages = bot_stats.eligible_messages; + context.sent_messages = bot_stats.sent_messages; + context.send_failures = bot_stats.send_failures; +- if (command_id == BOT_COMMAND_DICE) { ++ context.suppressed_responses = bot_stats.suppressed_responses; ++ context.pending_responses = bot_stats.pending_responses; ++ context.emergency_forwards = bot_stats.emergency_forwards; ++ context.emergency_forward_failures = bot_stats.emergency_forward_failures; ++ if (command_id == BOT_COMMAND_DICE || command_id == BOT_COMMAND_MAGIC8) { + getRNG()->random((uint8_t *)&context.random_seed, sizeof(context.random_seed)); + } +- if (command_id == BOT_COMMAND_STATUS) { ++ if (command_id == BOT_COMMAND_STATUS || command_id == BOT_COMMAND_STATS) { + context.battery_millivolts = board.getBattMilliVolts(); + context.storage_used_kb = _store->getStorageUsedKb(); + context.storage_total_kb = _store->getStorageTotalKb(); +@@ -881,6 +892,25 @@ void MyMesh::buildBotCommandContext(BotCommandContext &context, BotCommandId com + if (getChannel(i, channel) && channel.name[0]) context.channel_count++; + } + } ++ if (command_id == BOT_COMMAND_VERSION) { ++ StrHelper::strzcpy(context.firmware_version, FIRMWARE_VERSION, sizeof(context.firmware_version)); ++ StrHelper::strzcpy(context.firmware_build_date, FIRMWARE_BUILD_DATE, sizeof(context.firmware_build_date)); ++ } ++ if (command_id == BOT_COMMAND_STATS) { ++ context.queue_depth = (uint8_t)_mgr->getOutboundTotal(); ++ context.noise_floor = (int16_t)_radio->getNoiseFloor(); ++ context.last_rssi = (int8_t)radio_driver.getLastRSSI(); ++ context.last_snr_quarters = (int8_t)(radio_driver.getLastSNR() * 4); ++ context.tx_airtime_seconds = getTotalAirTime() / 1000; ++ context.rx_airtime_seconds = getReceiveAirTime() / 1000; ++ context.packets_recv = radio_driver.getPacketsRecv(); ++ context.packets_sent = radio_driver.getPacketsSent(); ++ context.flood_sent = getNumSentFlood(); ++ context.direct_sent = getNumSentDirect(); ++ context.flood_recv = getNumRecvFlood(); ++ context.direct_recv = getNumRecvDirect(); ++ context.packets_recv_errors = radio_driver.getPacketsRecvErrors(); ++ } + } + + bool MyMesh::enqueueBotResponse(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, +@@ -1024,6 +1054,13 @@ void MyMesh::recordBotObservation(const BotMessage &message, const ContactInfo * + + BotCommandContext context; + buildBotCommandContext(context, command.id); ++ if (command.id == BOT_COMMAND_PATH) { ++ context.path_len = message.path_len; ++ context.path_hash_size = message.path_hash_size; ++ context.path_hash_count = message.path_hash_count; ++ context.path_snr_quarters = message.packet_snr_quarters; ++ context.path = message.path; ++ } + char response[BOT_MAX_RESPONSE_LEN + 1]; + BotCommandResult result = 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) { +@@ -1232,7 +1269,7 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe + if (_ui) _ui->newMsg(path_len, channel_name, text, offline_queue_len); + #endif + #if CMESH_BOT_ENABLED +- observeBotChannelMessage(channel_idx, channel_name, text, timestamp); ++ observeBotChannelMessage(channel_idx, channel_name, text, timestamp, pkt); + #endif + } + +diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h +index 6f8d432..e25f9a5 100644 +--- a/examples/companion_radio/MyMesh.h ++++ b/examples/companion_radio/MyMesh.h +@@ -209,7 +209,7 @@ private: + void observeBotDirectMessage(const ContactInfo &from, uint32_t sender_timestamp, const uint8_t *sender_prefix, + size_t sender_prefix_len, const char *text); + void observeBotChannelMessage(uint8_t channel_idx, const char *channel_name, const char *text, +- uint32_t sender_timestamp); ++ uint32_t sender_timestamp, const mesh::Packet *packet); + void recordBotObservation(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx); + bool observeKnownBotResponse(const BotMessage &message, bool authoritative_sender); + void buildBotCommandContext(BotCommandContext &context, BotCommandId command_id); diff --git a/tests/firmware_bot/test_firmware_bot.cpp b/tests/firmware_bot/test_firmware_bot.cpp index fedf809..e50f1fd 100644 --- a/tests/firmware_bot/test_firmware_bot.cpp +++ b/tests/firmware_bot/test_firmware_bot.cpp @@ -137,6 +137,14 @@ static void test_bot_prefs_validation_and_command_mask() { BotCommandId id = BOT_COMMAND_NONE; assert(BotPrefsCodec::commandIdForName("ROLL", &id)); assert(id == BOT_COMMAND_DICE); + assert(BotPrefsCodec::commandIdForName("t", &id)); + assert(id == BOT_COMMAND_TEST); + assert(BotPrefsCodec::commandIdForName("ver", &id)); + assert(id == BOT_COMMAND_VERSION); + assert(BotPrefsCodec::commandIdForName("8ball", &id)); + assert(id == BOT_COMMAND_MAGIC8); + assert(BotPrefsCodec::commandIdForName("p", &id)); + assert(id == BOT_COMMAND_PATH); assert(!BotPrefsCodec::commandIdForName("nope", &id)); } @@ -230,6 +238,14 @@ static void test_parse_command() { BotCommand command; assert(!FirmwareBot::parseCommand("hello bot", 9, &command)); + assert(!FirmwareBot::parseCommand("ping", 4, &command)); + assert(FirmwareBot::parseCommand("ping", 4, &command, true)); + assert(command.id == BOT_COMMAND_PING); + assert(command.args_len == 0); + assert(FirmwareBot::parseCommand("status please", 13, &command, true)); + assert(command.id == BOT_COMMAND_STATUS); + assert(strcmp(command.args, "please") == 0); + assert(!FirmwareBot::parseCommand("random prose", 12, &command, true)); assert(FirmwareBot::parseCommand("!PING", 5, &command)); assert(command.id == BOT_COMMAND_PING); assert(strcmp(command.name, "ping") == 0); @@ -253,6 +269,17 @@ static void test_parse_command() { assert(FirmwareBot::parseCommand("!hi", 3, &command)); assert(command.id == BOT_COMMAND_HELLO); + assert(FirmwareBot::parseCommand("t", 1, &command, true)); + assert(command.id == BOT_COMMAND_TEST); + assert(FirmwareBot::parseCommand("ver", 3, &command, true)); + assert(command.id == BOT_COMMAND_VERSION); + assert(FirmwareBot::parseCommand("8ball", 5, &command, true)); + assert(command.id == BOT_COMMAND_MAGIC8); + assert(FirmwareBot::parseCommand("p", 1, &command, true)); + assert(command.id == BOT_COMMAND_PATH); + assert(FirmwareBot::parseCommand("decode", 6, &command, true)); + assert(command.id == BOT_COMMAND_PATH); + assert(FirmwareBot::parseCommand("!wat", 4, &command)); assert(command.id == BOT_COMMAND_UNKNOWN); @@ -307,6 +334,16 @@ static BotCommandContext make_context() { context.send_failures = 1; context.random_seed = 0x12345678; context.channel_count = 4; + strncpy(context.firmware_version, "v1.test", sizeof(context.firmware_version) - 1); + strncpy(context.firmware_build_date, "14 May 2026", sizeof(context.firmware_build_date) - 1); + context.suppressed_responses = 3; + context.pending_responses = 6; + context.emergency_forwards = 1; + context.emergency_forward_failures = 0; + context.packets_recv = 77; + context.packets_sent = 44; + context.packets_recv_errors = 2; + context.queue_depth = 3; return context; } @@ -338,8 +375,25 @@ static void test_command_outputs() { result = run_command("!help", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strstr(out, "!ping") != NULL); - assert(strstr(out, "!channels") != NULL); + assert(strstr(out, "ping") != NULL); + assert(strstr(out, "path") != NULL); + + result = run_command("!version", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Firmware v1.test built 14 May 2026") == 0); + + 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); + + result = run_command("!magic8", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strstr(out, "Magic 8-ball:") == out); + + result = run_command("!path", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Path unavailable") == 0); result = run_command("!channels", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); @@ -353,7 +407,28 @@ static void test_command_outputs() { result = run_command("!wat", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strcmp(out, "Unknown command. Try !help") == 0); + assert(strcmp(out, "Unknown command. Try help") == 0); +} + +static void test_path_command() { + char out[BOT_MAX_RESPONSE_LEN + 1]; + BotCommand command; + assert(FirmwareBot::parseCommand("!path", 5, &command)); + BotCommandContext context = make_context(); + uint8_t path[] = { 0x12, 0x34, 0xab, 0xcd, 0x00, 0x01 }; + context.path = path; + context.path_len = (uint8_t)(((2 - 1) << 6) | 3); + context.path_hash_size = 2; + context.path_hash_count = 3; + 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); + + context.path = NULL; + result = BotCommands::executeCommand(command, context, out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Path unavailable") == 0); } static void test_dice_command() { @@ -372,17 +447,25 @@ static void test_dice_command() { assert(strchr(out, '+') != NULL); assert(strchr(out, '=') != NULL); - result = run_command("!roll d7", out, sizeof(out)); + result = run_command("!roll 20", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strncmp(out, "Usage: !roll", 12) == 0); + assert(strncmp(out, "Rolled d20: ", 12) == 0); + + result = run_command("!roll d100", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strncmp(out, "Rolled d100: ", 13) == 0); + + result = run_command("!roll d1", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strncmp(out, "Usage: roll", 11) == 0); result = run_command("!roll 0d6", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strncmp(out, "Usage: !roll", 12) == 0); + assert(strncmp(out, "Usage: roll", 11) == 0); result = run_command("!roll 11d6", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); - assert(strncmp(out, "Usage: !roll", 12) == 0); + assert(strncmp(out, "Usage: roll", 11) == 0); } static void test_command_truncation() { @@ -826,6 +909,7 @@ int main() { test_parse_command(); test_response_write(); test_command_outputs(); + test_path_command(); test_dice_command(); test_command_truncation(); test_command_cooldown();