From b26c30ab5fb089ada0d828b9f6a45022b1ac43d6 Mon Sep 17 00:00:00 2001 From: cj-vana Date: Thu, 14 May 2026 15:56:48 -0600 Subject: [PATCH] =?UTF-8?q?forge:=20step=204=20=E2=80=94=20wire=20bot=20ru?= =?UTF-8?q?ntime=20observation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...0001-Add-companion-firmware-bot-core.patch | 4 +- ...-Wire-companion-firmware-bot-runtime.patch | 316 ++++++++++++++++++ tests/firmware_bot/test_firmware_bot.cpp | 15 + 3 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 patches/meshcore/0002-Wire-companion-firmware-bot-runtime.patch diff --git a/patches/meshcore/0001-Add-companion-firmware-bot-core.patch b/patches/meshcore/0001-Add-companion-firmware-bot-core.patch index fd05928..a028111 100644 --- a/patches/meshcore/0001-Add-companion-firmware-bot-core.patch +++ b/patches/meshcore/0001-Add-companion-firmware-bot-core.patch @@ -1,7 +1,7 @@ From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: cj-vana -Date: Thu, 14 May 2026 13:36:50 -0600 -Subject: [PATCH] Add companion firmware bot core +Date: Thu, 14 May 2026 14:03:07 -0600 +Subject: [PATCH 1/2] Add companion firmware bot core --- examples/companion_radio/BotPolicy.cpp | 58 +++++++ diff --git a/patches/meshcore/0002-Wire-companion-firmware-bot-runtime.patch b/patches/meshcore/0002-Wire-companion-firmware-bot-runtime.patch new file mode 100644 index 0000000..daec0bb --- /dev/null +++ b/patches/meshcore/0002-Wire-companion-firmware-bot-runtime.patch @@ -0,0 +1,316 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: cj-vana +Date: Thu, 14 May 2026 14:03:07 -0600 +Subject: [PATCH 2/2] Wire companion firmware bot runtime + +--- + examples/companion_radio/FirmwareBot.cpp | 24 ++++ + examples/companion_radio/FirmwareBot.h | 2 + + examples/companion_radio/MyMesh.cpp | 136 ++++++++++++++++++++++- + examples/companion_radio/MyMesh.h | 24 ++++ + 4 files changed, 184 insertions(+), 2 deletions(-) + +diff --git a/examples/companion_radio/FirmwareBot.cpp b/examples/companion_radio/FirmwareBot.cpp +index c9447282..2d4b7395 100644 +--- a/examples/companion_radio/FirmwareBot.cpp ++++ b/examples/companion_radio/FirmwareBot.cpp +@@ -150,6 +150,30 @@ bool parseCommand(const char* text, size_t text_len, BotCommand* command) { + return true; + } + ++bool splitChannelText(const char* text, size_t text_len, char* sender, size_t sender_len, const char** body, ++ size_t* body_len) { ++ if (body) *body = text; ++ if (body_len) *body_len = text_len; ++ if (!text) return false; ++ ++ for (size_t i = 0; i < text_len; i++) { ++ if (text[i] == 0) break; ++ if (text[i] == ':' && i + 1 < text_len && text[i + 1] == ' ') { ++ if (sender && sender_len > 0) { ++ size_t copy_len = i; ++ if (copy_len >= sender_len) copy_len = sender_len - 1; ++ if (copy_len > 0) memcpy(sender, text, copy_len); ++ sender[copy_len] = 0; ++ } ++ size_t start = i + 2; ++ if (body) *body = &text[start]; ++ if (body_len) *body_len = text_len - start; ++ return true; ++ } ++ } ++ return false; ++} ++ + BotWriteResult writeResponse(char* output, size_t output_len, const char* text, size_t text_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 8fce16ab..8b267b89 100644 +--- a/examples/companion_radio/FirmwareBot.h ++++ b/examples/companion_radio/FirmwareBot.h +@@ -6,6 +6,8 @@ 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 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); + BotFingerprint fingerprintFor(const BotMessage& message); + BotCommandId commandIdForName(const char* name, size_t len); +diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp +index e8c1914b..481038f4 100644 +--- a/examples/companion_radio/MyMesh.cpp ++++ b/examples/companion_radio/MyMesh.cpp +@@ -3,6 +3,11 @@ + #include // needed for PlatformIO + #include + ++#if CMESH_BOT_ENABLED ++#include "BotPolicy.h" ++#include "FirmwareBot.h" ++#endif ++ + #define CMD_APP_START 1 + #define CMD_SEND_TXT_MSG 2 + #define CMD_SEND_CHANNEL_TXT_MSG 3 +@@ -105,8 +110,21 @@ + #define DIRECT_SEND_PERHOP_EXTRA_MILLIS 250 + #define LAZY_CONTACTS_WRITE_DELAY 5000 + ++#if CMESH_BOT_ENABLED ++#define BOT_AUTO_LOCAL_FIRST_DELAY_MILLIS 60000UL ++#define BOT_AUTO_ADVERT_INTERVAL_MILLIS (24UL * 60UL * 60UL * 1000UL) ++#endif ++ + #define PUBLIC_GROUP_PSK "izOH6cXN6mrJ5e26oRXNcg==" + ++#if CMESH_BOT_ENABLED ++static size_t botBoundedStrLen(const char *value, size_t max_len) { ++ size_t len = 0; ++ while (value && len < max_len && value[len] != 0) len++; ++ return len; ++} ++#endif ++ + // these are _pushed_ to client app at any time + #define PUSH_CODE_ADVERT 0x80 + #define PUSH_CODE_PATH_UPDATED 0x81 +@@ -514,6 +532,9 @@ void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t + const char *text) { + 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); ++#endif + } + + void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, +@@ -528,8 +549,104 @@ void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uin + // from.sync_since change needs to be persisted + 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); ++#endif ++} ++ ++#if CMESH_BOT_ENABLED ++void MyMesh::observeBotDirectMessage(const ContactInfo &from, uint32_t sender_timestamp, const uint8_t *sender_prefix, ++ size_t sender_prefix_len, const char *text) { ++ BotMessage message; ++ memset(&message, 0, sizeof(message)); ++ message.channel_kind = BotPolicy::classifyChannel(NULL, 0, true); ++ StrHelper::strzcpy(message.sender_name, from.name, sizeof(message.sender_name)); ++ size_t prefix_len = sender_prefix_len; ++ if (prefix_len > sizeof(message.sender_key_prefix)) prefix_len = sizeof(message.sender_key_prefix); ++ if (sender_prefix && prefix_len > 0) memcpy(message.sender_key_prefix, sender_prefix, prefix_len); ++ message.sender_timestamp = sender_timestamp; ++ FirmwareBot::normalizeText(text, botBoundedStrLen(text, BOT_MAX_TEXT_LEN), message.text, sizeof(message.text), ++ &message.text_len); ++ recordBotObservation(message); ++} ++ ++void MyMesh::observeBotChannelMessage(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); ++ message.channel_kind = BotPolicy::classifyChannel(channel_name, channel_len, false); ++ if (channel_name && channel_len > 0) { ++ memcpy(message.channel_name, channel_name, channel_len); ++ message.channel_name[channel_len] = 0; ++ } ++ message.sender_timestamp = sender_timestamp; ++ ++ const char *body = 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); ++} ++ ++void MyMesh::recordBotObservation(const BotMessage &message) { ++ bot_stats.observed_messages++; ++ BotPolicyDecision decision = BotPolicy::decide(message.channel_kind); ++ if (decision == BOT_POLICY_IGNORE) { ++ bot_stats.ignored_messages++; ++ return; ++ } ++ if (decision == BOT_POLICY_EMERGENCY_FORWARD) { ++ bot_stats.emergency_messages++; ++ return; ++ } ++ ++ 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] == '/')) { ++ bot_stats.parse_errors++; ++ } ++} ++ ++bool MyMesh::sendBotSelfAdvert(bool flood) { ++ mesh::Packet* pkt; ++ if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) { ++ pkt = createSelfAdvert(_prefs.node_name); ++ } else { ++ pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon); ++ } ++ if (!pkt) return false; ++ ++ if (flood) { ++ TransportKey default_scope; ++ memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key)); ++ sendFloodScoped(default_scope, pkt, 0); ++ } else { ++ sendZeroHop(pkt); ++ } ++ return true; + } + ++void MyMesh::scheduleBotLocalAdvert(unsigned long interval_millis) { ++ next_bot_local_advert = interval_millis > 0 ? futureMillis(interval_millis) : 0; ++} ++ ++void MyMesh::scheduleBotFloodAdvert(unsigned long interval_millis) { ++ next_bot_flood_advert = interval_millis > 0 ? futureMillis(interval_millis) : 0; ++} ++ ++void MyMesh::tickBot() { ++ if (next_bot_local_advert && millisHasNowPassed(next_bot_local_advert)) { ++ sendBotSelfAdvert(false); ++ scheduleBotLocalAdvert(BOT_AUTO_ADVERT_INTERVAL_MILLIS); ++ } ++ if (next_bot_flood_advert && millisHasNowPassed(next_bot_flood_advert)) { ++ sendBotSelfAdvert(true); ++ scheduleBotFloodAdvert(BOT_AUTO_ADVERT_INTERVAL_MILLIS); ++ } ++} ++#endif ++ + void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp, + const char *text) { + int i = 0; +@@ -566,15 +683,17 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe + if (_ui) _ui->notify(UIEventType::channelMessage); + #endif + } +-#ifdef DISPLAY_CLASS +- // Get the channel name from the channel index + const char *channel_name = "Unknown"; + ChannelDetails channel_details; + if (getChannel(channel_idx, channel_details)) { + channel_name = channel_details.name; + } ++#ifdef DISPLAY_CLASS + if (_ui) _ui->newMsg(path_len, channel_name, text, offline_queue_len); + #endif ++#if CMESH_BOT_ENABLED ++ observeBotChannelMessage(channel_name, text, timestamp); ++#endif + } + + void MyMesh::onChannelDataRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint16_t data_type, +@@ -856,6 +975,11 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe + dirty_contacts_expiry = 0; + memset(advert_paths, 0, sizeof(advert_paths)); + memset(send_scope.key, 0, sizeof(send_scope.key)); ++#if CMESH_BOT_ENABLED ++ memset(&bot_stats, 0, sizeof(bot_stats)); ++ next_bot_local_advert = 0; ++ next_bot_flood_advert = 0; ++#endif + + // defaults + memset(&_prefs, 0, sizeof(_prefs)); +@@ -956,6 +1080,10 @@ void MyMesh::begin(bool has_display) { + radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); + MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", + radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); ++#if CMESH_BOT_ENABLED ++ scheduleBotLocalAdvert(BOT_AUTO_LOCAL_FIRST_DELAY_MILLIS); ++ scheduleBotFloodAdvert(BOT_AUTO_ADVERT_INTERVAL_MILLIS); ++#endif + } + + const char *MyMesh::getNodeName() { +@@ -2172,6 +2300,10 @@ void MyMesh::loop() { + dirty_contacts_expiry = 0; + } + ++#if CMESH_BOT_ENABLED ++ tickBot(); ++#endif ++ + #ifdef DISPLAY_CLASS + if (_ui) _ui->setHasConnection(_serial->isConnected()); + #endif +diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h +index aeff591c..d72a496a 100644 +--- a/examples/companion_radio/MyMesh.h ++++ b/examples/companion_radio/MyMesh.h +@@ -4,6 +4,13 @@ + #include + #include "AbstractUITask.h" + ++#ifndef CMESH_BOT_ENABLED ++#define CMESH_BOT_ENABLED 0 ++#endif ++#if CMESH_BOT_ENABLED ++#include "BotTypes.h" ++#endif ++ + /*------------ Frame Protocol --------------*/ + #define FIRMWARE_VER_CODE 11 + +@@ -192,6 +199,17 @@ private: + return _store->putBlobByKey(key, key_len, src_buf, len); + } + ++#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 tickBot(); ++ void scheduleBotLocalAdvert(unsigned long interval_millis); ++ void scheduleBotFloodAdvert(unsigned long interval_millis); ++ bool sendBotSelfAdvert(bool flood); ++#endif ++ + void checkCLIRescueCmd(); + void checkSerialInterface(); + bool isValidClientRepeatFreq(uint32_t f) const; +@@ -223,6 +241,12 @@ private: + + TransportKey send_scope; + ++#if CMESH_BOT_ENABLED ++ BotStats bot_stats; ++ unsigned long next_bot_local_advert; ++ unsigned long next_bot_flood_advert; ++#endif ++ + uint8_t cmd_frame[MAX_FRAME_SIZE + 1]; + uint8_t out_frame[MAX_FRAME_SIZE + 1]; + CayenneLPP telemetry; diff --git a/tests/firmware_bot/test_firmware_bot.cpp b/tests/firmware_bot/test_firmware_bot.cpp index 5f93f36..586eabf 100644 --- a/tests/firmware_bot/test_firmware_bot.cpp +++ b/tests/firmware_bot/test_firmware_bot.cpp @@ -8,14 +8,20 @@ static void test_channel_policy() { assert(BotPolicy::classifyChannel(NULL, 0, true) == BOT_CHANNEL_DM); assert(BotPolicy::decide(BOT_CHANNEL_DM) == BOT_POLICY_ALLOW_NORMAL); + assert(BotPolicy::isNormalAllowed(BOT_CHANNEL_DM)); assert(BotPolicy::classifyChannel("Public", 6, false) == BOT_CHANNEL_PUBLIC); assert(BotPolicy::decide(BOT_CHANNEL_PUBLIC) == BOT_POLICY_IGNORE); + assert(!BotPolicy::isNormalAllowed(BOT_CHANNEL_PUBLIC)); assert(BotPolicy::classifyChannel("#bot", 4, false) == BOT_CHANNEL_BOT); assert(BotPolicy::decide(BOT_CHANNEL_BOT) == BOT_POLICY_ALLOW_NORMAL); + assert(BotPolicy::isNormalAllowed(BOT_CHANNEL_BOT)); assert(BotPolicy::classifyChannel("testing", 7, false) == BOT_CHANNEL_TESTING); assert(BotPolicy::decide(BOT_CHANNEL_TESTING) == BOT_POLICY_ALLOW_NORMAL); + assert(BotPolicy::isNormalAllowed(BOT_CHANNEL_TESTING)); assert(BotPolicy::classifyChannel("#emergency", 10, false) == BOT_CHANNEL_EMERGENCY); assert(BotPolicy::decide(BOT_CHANNEL_EMERGENCY) == BOT_POLICY_EMERGENCY_FORWARD); + assert(BotPolicy::isEmergency(BOT_CHANNEL_EMERGENCY)); + assert(!BotPolicy::isNormalAllowed(BOT_CHANNEL_EMERGENCY)); assert(BotPolicy::classifyChannel("#botnet", 7, false) == BOT_CHANNEL_OTHER); assert(BotPolicy::decide(BOT_CHANNEL_OTHER) == BOT_POLICY_IGNORE); } @@ -56,6 +62,15 @@ static void test_normalize_truncation() { } static void test_parse_command() { + const char* body = NULL; + size_t body_len = 0; + char sender[BOT_MAX_SENDER_NAME_LEN + 1]; + assert(FirmwareBot::splitChannelText("alice: !ping", 12, sender, sizeof(sender), &body, &body_len)); + assert(strcmp(sender, "alice") == 0); + assert(body_len == 5); + assert(strncmp(body, "!ping", body_len) == 0); + assert(!FirmwareBot::splitChannelText("alice says !ping", 16, sender, sizeof(sender), &body, &body_len)); + BotCommand command; assert(!FirmwareBot::parseCommand("hello bot", 9, &command)); assert(FirmwareBot::parseCommand("!PING", 5, &command));