From 942a319e0167fd86e7207578068fedc9f81ea793 Mon Sep 17 00:00:00 2001 From: cj-vana Date: Thu, 14 May 2026 16:59:05 -0600 Subject: [PATCH] =?UTF-8?q?forge:=20step=205=20=E2=80=94=20add=20firmware?= =?UTF-8?q?=20bot=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0003-Add-firmware-bot-commands.patch | 596 ++++++++++++++++++ tests/firmware_bot/run_tests.py | 1 + tests/firmware_bot/test_firmware_bot.cpp | 142 +++++ 3 files changed, 739 insertions(+) create mode 100644 patches/meshcore/0003-Add-firmware-bot-commands.patch diff --git a/patches/meshcore/0003-Add-firmware-bot-commands.patch b/patches/meshcore/0003-Add-firmware-bot-commands.patch new file mode 100644 index 0000000..16f8be3 --- /dev/null +++ b/patches/meshcore/0003-Add-firmware-bot-commands.patch @@ -0,0 +1,596 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: cj-vana +Date: Thu, 14 May 2026 14:03:07 -0600 +Subject: [PATCH 3/3] Add firmware bot commands + +--- + examples/companion_radio/BotCommands.cpp | 171 +++++++++++++++++++++++++++++++ + examples/companion_radio/BotCommands.h | 10 ++ + examples/companion_radio/BotTypes.h | 43 +++++++- + examples/companion_radio/FirmwareBot.cpp | 35 +++++++ + examples/companion_radio/FirmwareBot.h | 5 + + examples/companion_radio/MyMesh.cpp | 131 +++++++++++++++++++++-- + examples/companion_radio/MyMesh.h | 22 +++- + 7 files changed, 406 insertions(+), 11 deletions(-) +diff --git a/examples/companion_radio/BotCommands.cpp b/examples/companion_radio/BotCommands.cpp +new file mode 100644 +index 00000000..5b13fbfe +--- /dev/null ++++ b/examples/companion_radio/BotCommands.cpp +@@ -0,0 +1,171 @@ ++#include "BotCommands.h" ++ ++#include ++#include ++#include ++#include ++ ++namespace { ++ ++BotCommandResult makeResult(BotCommandResultCode code, size_t text_len) { ++ BotCommandResult result = { code, text_len }; ++ return result; ++} ++ ++size_t boundedStrLen(const char* value, size_t max_len) { ++ size_t len = 0; ++ while (value && len < max_len && value[len] != 0) len++; ++ return len; ++} ++ ++BotCommandResult writeText(char* output, size_t output_len, const char* text) { ++ if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ ++ size_t text_len = boundedStrLen(text, BOT_MAX_RESPONSE_LEN + 1); ++ size_t copy_len = text_len; ++ if (copy_len + 1 > output_len) copy_len = output_len - 1; ++ if (copy_len > 0) memcpy(output, text, copy_len); ++ output[copy_len] = 0; ++ ++ return makeResult(copy_len < text_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, copy_len); ++} ++ ++BotCommandResult writeFormatted(char* output, size_t output_len, const char* format, ...) { ++ if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ ++ va_list args; ++ va_start(args, format); ++ int n = vsnprintf(output, output_len, format, args); ++ va_end(args); ++ ++ if (n < 0) { ++ output[0] = 0; ++ return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ } ++ ++ size_t written = (size_t)n; ++ if (written >= output_len) written = output_len - 1; ++ return makeResult((size_t)n >= output_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, written); ++} ++ ++bool parseUInt(const char* text, size_t len, size_t* pos, 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; ++ (*pos)++; ++ } ++ if (*pos == start) return false; ++ *value = (uint16_t)parsed; ++ return true; ++} ++ ++bool isSupportedSides(uint16_t sides) { ++ return sides == 4 || sides == 6 || sides == 8 || sides == 10 || sides == 12 || sides == 16 || sides == 20; ++} ++ ++bool parseDice(const BotCommand& command, uint16_t* count, uint16_t* sides) { ++ *count = 1; ++ *sides = 6; ++ if (command.args_len == 0) return true; ++ ++ const char* text = command.args; ++ size_t len = command.args_len; ++ size_t pos = 0; ++ ++ if (text[pos] == 'd' || text[pos] == 'D') { ++ pos++; ++ if (!parseUInt(text, len, &pos, sides)) return false; ++ } else { ++ uint16_t first = 0; ++ if (!parseUInt(text, len, &pos, &first)) return false; ++ if (pos < len && (text[pos] == 'd' || text[pos] == 'D')) { ++ *count = first; ++ pos++; ++ if (!parseUInt(text, len, &pos, sides)) return false; ++ } else { ++ *sides = first; ++ } ++ } ++ ++ return pos == len && *count >= 1 && *count <= 10 && isSupportedSides(*sides); ++} ++ ++uint16_t rollOnce(uint32_t* state, uint16_t sides) { ++ *state = (*state * 1664525UL) + 1013904223UL; ++ return (uint16_t)((*state >> 16) % sides) + 1; ++} ++ ++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"); ++ } ++ ++ uint32_t state = context.random_seed ^ ((uint32_t)count << 16) ^ sides; ++ 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, "Rolled d%u: ", (unsigned)sides) ++ : snprintf(output, output_len, "Rolled %ud%u: ", (unsigned)count, (unsigned)sides); ++ if (written < 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); ++ ++ size_t pos = (size_t)written; ++ 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; ++ } ++ } ++ 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; ++ } ++ ++ size_t actual = boundedStrLen(output, output_len); ++ return makeResult(pos >= output_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, actual); ++} ++ ++} ++ ++namespace BotCommands { ++ ++BotCommandResult executeCommand(const BotCommand& command, const BotCommandContext& context, char* output, ++ 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"); ++ case BOT_COMMAND_PING: ++ return writeText(output, output_len, "Pong!"); ++ case BOT_COMMAND_TEST: ++ return writeText(output, output_len, "Bot test OK"); ++ 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: ++ return writeText(output, output_len, "Colorado Mesh firmware bot: local commands only, no internet required."); ++ 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, ++ (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, "Configured channels: %u. Normal bot replies: DM, #bot, #testing.", ++ (unsigned)context.channel_count); ++ case BOT_COMMAND_UNKNOWN: ++ return writeText(output, output_len, "Unknown command. Try !help"); ++ default: ++ return makeResult(BOT_COMMAND_RESULT_NOT_HANDLED, 0); ++ } ++} ++ ++} +diff --git a/examples/companion_radio/BotCommands.h b/examples/companion_radio/BotCommands.h +new file mode 100644 +index 00000000..b0b4cddb +--- /dev/null ++++ b/examples/companion_radio/BotCommands.h +@@ -0,0 +1,10 @@ ++#pragma once ++ ++#include "BotTypes.h" ++ ++namespace BotCommands { ++ ++BotCommandResult executeCommand(const BotCommand& command, const BotCommandContext& context, char* output, ++ size_t output_len); ++ ++} +diff --git a/examples/companion_radio/BotTypes.h b/examples/companion_radio/BotTypes.h +index 869ae3ae..a7575a7c 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_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 + #define BOT_SENDER_KEY_PREFIX_LEN 6 + + enum BotChannelKind : uint8_t { +@@ -35,9 +38,17 @@ enum BotCommandId : uint8_t { + BOT_COMMAND_ABOUT, + BOT_COMMAND_DICE, + BOT_COMMAND_STATUS, ++ BOT_COMMAND_CHANNELS, + BOT_COMMAND_UNKNOWN + }; + ++enum BotCommandResultCode : uint8_t { ++ BOT_COMMAND_RESULT_NOT_HANDLED = 0, ++ BOT_COMMAND_RESULT_OK, ++ BOT_COMMAND_RESULT_TRUNCATED, ++ BOT_COMMAND_RESULT_NO_SPACE ++}; ++ + enum BotWriteResult : uint8_t { + BOT_WRITE_OK = 0, + BOT_WRITE_TRUNCATED, +@@ -73,6 +84,31 @@ struct BotResponse { + bool truncated; + }; + ++struct BotCommandContext { ++ char node_name[BOT_MAX_SENDER_NAME_LEN + 1]; ++ uint32_t uptime_seconds; ++ uint16_t battery_millivolts; ++ uint32_t storage_used_kb; ++ uint32_t storage_total_kb; ++ uint32_t observed_messages; ++ uint32_t ignored_messages; ++ uint32_t eligible_messages; ++ uint32_t sent_messages; ++ uint32_t send_failures; ++ uint32_t random_seed; ++ uint8_t channel_count; ++}; ++ ++struct BotCommandResult { ++ BotCommandResultCode code; ++ size_t text_len; ++}; ++ ++struct BotCommandCooldown { ++ BotCommandId command_id; ++ uint32_t expires_at_millis; ++}; ++ + struct BotPrefs { + bool enabled; + uint16_t normal_delay_ms; +@@ -90,10 +126,15 @@ struct BotStats { + uint32_t eligible_messages; + uint32_t emergency_messages; + uint32_t parse_errors; ++ uint32_t sent_messages; ++ uint32_t send_failures; + }; + + static_assert(sizeof(BotMessage) <= 240, "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) <= 96, "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(BotPrefs) <= 128, "BotPrefs RAM budget exceeded"); +-static_assert(sizeof(BotStats) <= 32, "BotStats RAM budget exceeded"); ++static_assert(sizeof(BotStats) <= 48, "BotStats RAM budget exceeded"); +diff --git a/examples/companion_radio/FirmwareBot.cpp b/examples/companion_radio/FirmwareBot.cpp +index 2d4b7395..78b66937 100644 +--- a/examples/companion_radio/FirmwareBot.cpp ++++ b/examples/companion_radio/FirmwareBot.cpp +@@ -111,9 +111,44 @@ BotCommandId commandIdForName(const char* name, size_t len) { + 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; + return BOT_COMMAND_UNKNOWN; + } + ++size_t maxResponseLenForChannel(BotChannelKind channel_kind) { ++ return channel_kind == BOT_CHANNEL_DM ? BOT_MAX_RESPONSE_LEN : BOT_MAX_GROUP_RESPONSE_LEN; ++} ++ ++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; ++ 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; ++ } ++ return false; ++} ++ ++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; ++ ++ size_t slot = cooldown_count; ++ for (size_t i = 0; i < cooldown_count; i++) { ++ if (cooldowns[i].command_id == command_id) { ++ slot = i; ++ break; ++ } ++ if (slot == cooldown_count && ++ (cooldowns[i].command_id == BOT_COMMAND_NONE || (int32_t)(cooldowns[i].expires_at_millis - now_millis) <= 0)) { ++ slot = i; ++ } ++ } ++ if (slot == cooldown_count) slot = 0; ++ ++ cooldowns[slot].command_id = command_id; ++ cooldowns[slot].expires_at_millis = now_millis + cooldown_millis; ++} ++ + bool parseCommand(const char* text, size_t text_len, BotCommand* command) { + if (!command) return false; + memset(command, 0, sizeof(*command)); +diff --git a/examples/companion_radio/FirmwareBot.h b/examples/companion_radio/FirmwareBot.h +index 8b267b89..b718985f 100644 +--- a/examples/companion_radio/FirmwareBot.h ++++ b/examples/companion_radio/FirmwareBot.h +@@ -11,5 +11,10 @@ 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); + BotFingerprint fingerprintFor(const BotMessage& message); + BotCommandId commandIdForName(const char* name, size_t len); ++size_t maxResponseLenForChannel(BotChannelKind channel_kind); ++bool isCommandOnCooldown(const BotCommandCooldown* cooldowns, size_t cooldown_count, BotCommandId command_id, ++ uint32_t now_millis); ++void recordCommandCooldown(BotCommandCooldown* cooldowns, size_t cooldown_count, BotCommandId command_id, ++ uint32_t now_millis, uint32_t cooldown_millis); + + } +diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp +index 1596875a..72873d37 100644 +--- a/examples/companion_radio/MyMesh.cpp ++++ b/examples/companion_radio/MyMesh.cpp +@@ -4,6 +4,7 @@ + #include + + #if CMESH_BOT_ENABLED ++#include "BotCommands.h" + #include "BotPolicy.h" + #include "FirmwareBot.h" + #endif +@@ -567,10 +568,11 @@ void MyMesh::observeBotDirectMessage(const ContactInfo &from, uint32_t sender_ti + message.sender_timestamp = sender_timestamp; + FirmwareBot::normalizeText(text, botBoundedStrLen(text, BOT_MAX_TEXT_LEN), message.text, sizeof(message.text), + &message.text_len); +- recordBotObservation(message); ++ recordBotObservation(message, &from, 0xFF); + } + +-void MyMesh::observeBotChannelMessage(const char *channel_name, const char *text, uint32_t sender_timestamp) { ++void MyMesh::observeBotChannelMessage(uint8_t channel_idx, const char *channel_name, const char *text, ++ uint32_t sender_timestamp) { + BotMessage message; + memset(&message, 0, sizeof(message)); + size_t channel_len = botBoundedStrLen(channel_name, BOT_MAX_CHANNEL_NAME_LEN); +@@ -585,10 +587,60 @@ void MyMesh::observeBotChannelMessage(const char *channel_name, const char *text + size_t body_len = botBoundedStrLen(text, BOT_MAX_TEXT_LEN); + FirmwareBot::splitChannelText(text, body_len, message.sender_name, sizeof(message.sender_name), &body, &body_len); + FirmwareBot::normalizeText(body, body_len, message.text, sizeof(message.text), &message.text_len); +- recordBotObservation(message); ++ recordBotObservation(message, NULL, channel_idx); ++} ++ ++void MyMesh::buildBotCommandContext(BotCommandContext &context, BotCommandId command_id) { ++ memset(&context, 0, sizeof(context)); ++ StrHelper::strzcpy(context.node_name, _prefs.node_name, sizeof(context.node_name)); ++ context.uptime_seconds = _ms->getMillis() / 1000; ++ context.observed_messages = bot_stats.observed_messages; ++ context.ignored_messages = bot_stats.ignored_messages; ++ 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) { ++ getRNG()->random((uint8_t *)&context.random_seed, sizeof(context.random_seed)); ++ } ++ if (command_id == BOT_COMMAND_STATUS) { ++ context.battery_millivolts = board.getBattMilliVolts(); ++ context.storage_used_kb = _store->getStorageUsedKb(); ++ context.storage_total_kb = _store->getStorageTotalKb(); ++ } ++ if (command_id == BOT_COMMAND_CHANNELS) { ++ for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) { ++ ChannelDetails channel; ++ if (getChannel(i, channel) && channel.name[0]) context.channel_count++; ++ } ++ } + } + +-void MyMesh::recordBotObservation(const BotMessage &message) { ++bool MyMesh::enqueueBotResponse(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, ++ const char *text, size_t text_len) { ++ for (size_t i = 0; i < BOT_PENDING_RESPONSE_SLOTS; i++) { ++ PendingBotResponse *pending = &pending_bot_responses[i]; ++ if (!pending->active) { ++ pending->direct = message.channel_kind == BOT_CHANNEL_DM; ++ if (pending->direct) { ++ if (!direct_recipient) return false; ++ memcpy(pending->recipient_pub_key, direct_recipient->id.pub_key, sizeof(pending->recipient_pub_key)); ++ } else { ++ memset(pending->recipient_pub_key, 0, sizeof(pending->recipient_pub_key)); ++ } ++ pending->channel_idx = channel_idx; ++ pending->text_len = text_len; ++ size_t max_text_len = FirmwareBot::maxResponseLenForChannel(message.channel_kind); ++ if (pending->text_len > max_text_len) pending->text_len = max_text_len; ++ if (pending->text_len > 0) memcpy(pending->text, text, pending->text_len); ++ pending->text[pending->text_len] = 0; ++ pending->active = true; ++ return true; ++ } ++ } ++ return false; ++} ++ ++void MyMesh::recordBotObservation(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx) { + bot_stats.observed_messages++; + BotPolicyDecision decision = BotPolicy::decide(message.channel_kind); + if (decision == BOT_POLICY_IGNORE) { +@@ -601,10 +653,67 @@ void MyMesh::recordBotObservation(const BotMessage &message) { + } + + BotCommand command; +- if (FirmwareBot::parseCommand(message.text, message.text_len, &command)) { +- bot_stats.eligible_messages++; +- } else if (message.text_len > 0 && (message.text[0] == '!' || message.text[0] == '/')) { ++ if (!FirmwareBot::parseCommand(message.text, message.text_len, &command)) { ++ if (message.text_len > 0 && (message.text[0] == '!' || message.text[0] == '/')) bot_stats.parse_errors++; ++ return; ++ } ++ ++ if (FirmwareBot::isCommandOnCooldown(bot_command_cooldowns, BOT_COMMAND_COOLDOWN_SLOTS, command.id, _ms->getMillis())) { ++ bot_stats.ignored_messages++; ++ return; ++ } ++ ++ BotCommandContext context; ++ buildBotCommandContext(context, command.id); ++ 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) { + bot_stats.parse_errors++; ++ return; ++ } ++ ++ bot_stats.eligible_messages++; ++ FirmwareBot::recordCommandCooldown(bot_command_cooldowns, BOT_COMMAND_COOLDOWN_SLOTS, command.id, _ms->getMillis(), ++ BOT_COMMAND_COOLDOWN_MILLIS); ++ if (!enqueueBotResponse(message, direct_recipient, channel_idx, response, result.text_len)) { ++ bot_stats.send_failures++; ++ } ++} ++ ++void MyMesh::sendQueuedBotResponses() { ++ for (size_t i = 0; i < BOT_PENDING_RESPONSE_SLOTS; i++) { ++ PendingBotResponse *pending = &pending_bot_responses[i]; ++ if (!pending->active) continue; ++ ++ bool success = false; ++ if (pending->direct) { ++ ContactInfo *recipient = lookupContactByPubKey(pending->recipient_pub_key, PUB_KEY_SIZE); ++ if (recipient) { ++ uint32_t expected_ack = 0; ++ uint32_t est_timeout = 0; ++ uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); ++ int result = sendMessage(*recipient, timestamp, 0, pending->text, expected_ack, est_timeout); ++ success = result != MSG_SEND_FAILED; ++ if (success && expected_ack) { ++ expected_ack_table[next_ack_idx].msg_sent = _ms->getMillis(); ++ expected_ack_table[next_ack_idx].ack = expected_ack; ++ expected_ack_table[next_ack_idx].contact = recipient; ++ next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE; ++ } ++ } ++ } else if (pending->channel_idx != 0xFF) { ++ ChannelDetails channel; ++ uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); ++ success = getChannel(pending->channel_idx, channel) && ++ sendGroupMessage(timestamp, channel.channel, _prefs.node_name, pending->text, pending->text_len); ++ } ++ ++ if (success) { ++ bot_stats.sent_messages++; ++ } else { ++ bot_stats.send_failures++; ++ } ++ pending->active = false; + } + } + +@@ -636,6 +745,7 @@ void MyMesh::scheduleBotFloodAdvert(unsigned long interval_millis) { + } + + void MyMesh::tickBot() { ++ sendQueuedBotResponses(); + if (next_bot_local_advert && millisHasNowPassed(next_bot_local_advert)) { + sendBotSelfAdvert(false); + scheduleBotLocalAdvert(BOT_AUTO_ADVERT_INTERVAL_MILLIS); +@@ -692,7 +802,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_name, text, timestamp); ++ observeBotChannelMessage(channel_idx, channel_name, text, timestamp); + #endif + } + +@@ -977,6 +1087,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe + memset(send_scope.key, 0, sizeof(send_scope.key)); + #if CMESH_BOT_ENABLED + memset(&bot_stats, 0, sizeof(bot_stats)); ++ memset(pending_bot_responses, 0, sizeof(pending_bot_responses)); ++ memset(bot_command_cooldowns, 0, sizeof(bot_command_cooldowns)); + next_bot_local_advert = 0; + next_bot_flood_advert = 0; + #endif +@@ -992,6 +1104,9 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe + _prefs.tx_power_dbm = LORA_TX_POWER; + _prefs.gps_enabled = 0; // GPS disabled by default + _prefs.gps_interval = 0; // No automatic GPS updates by default ++#if CMESH_BOT_ENABLED ++ _prefs.path_hash_mode = 1; ++#endif + //_prefs.rx_delay_base = 10.0f; enable once new algo fixed + #if defined(USE_SX1262) || defined(USE_SX1268) + #ifdef SX126X_RX_BOOSTED_GAIN +diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h +index dc375c5b..ed915b28 100644 +--- a/examples/companion_radio/MyMesh.h ++++ b/examples/companion_radio/MyMesh.h +@@ -9,6 +9,8 @@ + #endif + #if CMESH_BOT_ENABLED + #include "BotTypes.h" ++#define BOT_PENDING_RESPONSE_SLOTS 2 ++#define BOT_COMMAND_COOLDOWN_SLOTS 9 + #endif + + /*------------ Frame Protocol --------------*/ +@@ -202,8 +204,13 @@ private: + #if CMESH_BOT_ENABLED + void observeBotDirectMessage(const ContactInfo &from, uint32_t sender_timestamp, const uint8_t *sender_prefix, + size_t sender_prefix_len, const char *text); +- void observeBotChannelMessage(const char *channel_name, const char *text, uint32_t sender_timestamp); +- void recordBotObservation(const BotMessage &message); ++ void observeBotChannelMessage(uint8_t channel_idx, const char *channel_name, const char *text, ++ uint32_t sender_timestamp); ++ void recordBotObservation(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx); ++ void buildBotCommandContext(BotCommandContext &context, BotCommandId command_id); ++ bool enqueueBotResponse(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, ++ const char *text, size_t text_len); ++ void sendQueuedBotResponses(); + void tickBot(); + void scheduleBotLocalAdvert(unsigned long interval_millis); + void scheduleBotFloodAdvert(unsigned long interval_millis); +@@ -242,7 +249,18 @@ private: + TransportKey send_scope; + + #if CMESH_BOT_ENABLED ++ struct PendingBotResponse { ++ bool active; ++ bool direct; ++ uint8_t recipient_pub_key[PUB_KEY_SIZE]; ++ uint8_t channel_idx; ++ char text[BOT_MAX_RESPONSE_LEN + 1]; ++ size_t text_len; ++ }; ++ + BotStats bot_stats; ++ PendingBotResponse pending_bot_responses[BOT_PENDING_RESPONSE_SLOTS]; ++ BotCommandCooldown bot_command_cooldowns[BOT_COMMAND_COOLDOWN_SLOTS]; + unsigned long next_bot_local_advert; + unsigned long next_bot_flood_advert; + #endif diff --git a/tests/firmware_bot/run_tests.py b/tests/firmware_bot/run_tests.py index 9038bf3..448c150 100644 --- a/tests/firmware_bot/run_tests.py +++ b/tests/firmware_bot/run_tests.py @@ -22,6 +22,7 @@ def main(): str(ROOT / "tests" / "firmware_bot" / "test_firmware_bot.cpp"), str(SRC_DIR / "FirmwareBot.cpp"), str(SRC_DIR / "BotPolicy.cpp"), + str(SRC_DIR / "BotCommands.cpp"), "-o", str(binary), ] diff --git a/tests/firmware_bot/test_firmware_bot.cpp b/tests/firmware_bot/test_firmware_bot.cpp index 586eabf..576a956 100644 --- a/tests/firmware_bot/test_firmware_bot.cpp +++ b/tests/firmware_bot/test_firmware_bot.cpp @@ -2,6 +2,7 @@ #include #include +#include "BotCommands.h" #include "BotPolicy.h" #include "FirmwareBot.h" @@ -87,6 +88,15 @@ static void test_parse_command() { assert(command.id == BOT_COMMAND_HELP); assert(strcmp(command.args, "please") == 0); + assert(FirmwareBot::parseCommand("!channels", 9, &command)); + assert(command.id == BOT_COMMAND_CHANNELS); + + assert(FirmwareBot::parseCommand("!commands", 9, &command)); + assert(command.id == BOT_COMMAND_HELP); + + assert(FirmwareBot::parseCommand("!hi", 3, &command)); + assert(command.id == BOT_COMMAND_HELLO); + assert(FirmwareBot::parseCommand("!wat", 4, &command)); assert(command.id == BOT_COMMAND_UNKNOWN); @@ -122,6 +132,133 @@ static BotMessage make_message(const char* channel, const char* text) { return message; } +static BotCommandContext make_context() { + BotCommandContext context; + memset(&context, 0, sizeof(context)); + strncpy(context.node_name, "cm-bot", sizeof(context.node_name) - 1); + context.uptime_seconds = 1234; + context.battery_millivolts = 4180; + context.storage_used_kb = 12; + context.storage_total_kb = 128; + context.observed_messages = 9; + context.ignored_messages = 2; + context.eligible_messages = 5; + context.sent_messages = 4; + context.send_failures = 1; + context.random_seed = 0x12345678; + context.channel_count = 4; + return context; +} + +static BotCommandResult run_command(const char* text, char* output, size_t output_len) { + BotCommand command; + assert(FirmwareBot::parseCommand(text, strlen(text), &command)); + BotCommandContext context = make_context(); + return BotCommands::executeCommand(command, context, output, output_len); +} + +static void test_command_outputs() { + char out[BOT_MAX_RESPONSE_LEN + 1]; + BotCommandResult result = run_command("!ping", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Pong!") == 0); + assert(result.text_len == strlen(out)); + + result = run_command("!test", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Bot test OK") == 0); + + result = run_command("!hello", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Hello 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, "!ping") != NULL); + assert(strstr(out, "!channels") != NULL); + + result = run_command("!channels", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Configured channels: 4. Normal bot replies: DM, #bot, #testing.") == 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, "sent 4 fail 1") != NULL); + + result = run_command("!wat", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strcmp(out, "Unknown command. Try !help") == 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 d6: ", 11) == 0); + + result = run_command("!roll d20", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strncmp(out, "Rolled d20: ", 12) == 0); + + result = run_command("!roll 2d6", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strncmp(out, "Rolled 2d6: ", 12) == 0); + assert(strchr(out, '+') != NULL); + assert(strchr(out, '=') != NULL); + + result = run_command("!roll d7", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strncmp(out, "Usage: !roll", 12) == 0); + + result = run_command("!roll 0d6", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strncmp(out, "Usage: !roll", 12) == 0); + + result = run_command("!roll 11d6", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_OK); + assert(strncmp(out, "Usage: !roll", 12) == 0); +} + +static void test_command_truncation() { + char out[12]; + BotCommandResult result = run_command("!about", out, sizeof(out)); + assert(result.code == BOT_COMMAND_RESULT_TRUNCATED); + assert(strcmp(out, "Colorado Me") == 0); + assert(result.text_len == strlen(out)); + + result = run_command("!ping", NULL, 0); + assert(result.code == BOT_COMMAND_RESULT_NO_SPACE); + assert(result.text_len == 0); +} + +static void test_command_cooldown() { + BotCommandCooldown cooldowns[2]; + memset(cooldowns, 0, sizeof(cooldowns)); + assert(!FirmwareBot::isCommandOnCooldown(cooldowns, 2, BOT_COMMAND_PING, 1000)); + FirmwareBot::recordCommandCooldown(cooldowns, 2, BOT_COMMAND_PING, 1000, 5000); + assert(FirmwareBot::isCommandOnCooldown(cooldowns, 2, BOT_COMMAND_PING, 5999)); + assert(!FirmwareBot::isCommandOnCooldown(cooldowns, 2, BOT_COMMAND_PING, 6000)); + assert(!FirmwareBot::isCommandOnCooldown(cooldowns, 2, BOT_COMMAND_STATUS, 2000)); + + 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)); +} + +static void test_group_response_cap() { + assert(BOT_MAX_GROUP_RESPONSE_LEN == BOT_MAX_TEXT_LEN - BOT_GROUP_RESPONSE_PREFIX_RESERVE); + assert(FirmwareBot::maxResponseLenForChannel(BOT_CHANNEL_DM) == BOT_MAX_RESPONSE_LEN); + assert(FirmwareBot::maxResponseLenForChannel(BOT_CHANNEL_BOT) == BOT_MAX_GROUP_RESPONSE_LEN); + assert(FirmwareBot::maxResponseLenForChannel(BOT_CHANNEL_TESTING) == BOT_MAX_GROUP_RESPONSE_LEN); + assert(BOT_MAX_GROUP_RESPONSE_LEN < BOT_MAX_RESPONSE_LEN); +} + static void test_fingerprint() { BotMessage a = make_message("#bot", "!PING"); BotMessage b = make_message("bot", "!ping"); @@ -142,6 +279,11 @@ int main() { test_normalize_truncation(); test_parse_command(); test_response_write(); + test_command_outputs(); + test_dice_command(); + test_command_truncation(); + test_command_cooldown(); + test_group_response_cap(); test_fingerprint(); printf("firmware_bot tests passed\n"); return 0;