forge: step 5 — add firmware bot commands

This commit is contained in:
cj-vana
2026-05-14 16:59:05 -06:00
parent b26c30ab5f
commit 942a319e01
3 changed files with 739 additions and 0 deletions

View File

@@ -0,0 +1,596 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: cj-vana <cj@depth23.online>
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 <ctype.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <string.h>
+
+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 <Mesh.h>
#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

View File

@@ -22,6 +22,7 @@ def main():
str(ROOT / "tests" / "firmware_bot" / "test_firmware_bot.cpp"), str(ROOT / "tests" / "firmware_bot" / "test_firmware_bot.cpp"),
str(SRC_DIR / "FirmwareBot.cpp"), str(SRC_DIR / "FirmwareBot.cpp"),
str(SRC_DIR / "BotPolicy.cpp"), str(SRC_DIR / "BotPolicy.cpp"),
str(SRC_DIR / "BotCommands.cpp"),
"-o", "-o",
str(binary), str(binary),
] ]

View File

@@ -2,6 +2,7 @@
#include <stdio.h> #include <stdio.h>
#include <string.h> #include <string.h>
#include "BotCommands.h"
#include "BotPolicy.h" #include "BotPolicy.h"
#include "FirmwareBot.h" #include "FirmwareBot.h"
@@ -87,6 +88,15 @@ static void test_parse_command() {
assert(command.id == BOT_COMMAND_HELP); assert(command.id == BOT_COMMAND_HELP);
assert(strcmp(command.args, "please") == 0); 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(FirmwareBot::parseCommand("!wat", 4, &command));
assert(command.id == BOT_COMMAND_UNKNOWN); assert(command.id == BOT_COMMAND_UNKNOWN);
@@ -122,6 +132,133 @@ static BotMessage make_message(const char* channel, const char* text) {
return message; 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() { static void test_fingerprint() {
BotMessage a = make_message("#bot", "!PING"); BotMessage a = make_message("#bot", "!PING");
BotMessage b = make_message("bot", "!ping"); BotMessage b = make_message("bot", "!ping");
@@ -142,6 +279,11 @@ int main() {
test_normalize_truncation(); test_normalize_truncation();
test_parse_command(); test_parse_command();
test_response_write(); test_response_write();
test_command_outputs();
test_dice_command();
test_command_truncation();
test_command_cooldown();
test_group_response_cap();
test_fingerprint(); test_fingerprint();
printf("firmware_bot tests passed\n"); printf("firmware_bot tests passed\n");
return 0; return 0;