From e6182f5e7bf450e6d068f476b6d87f35ae04c7ef Mon Sep 17 00:00:00 2001 From: cj-vana Date: Thu, 14 May 2026 21:11:40 -0600 Subject: [PATCH] =?UTF-8?q?forge:=20step=208=20=E2=80=94=20add=20bot=20pre?= =?UTF-8?q?fs=20and=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meshcore/0006-Add-bot-prefs-and-cli.patch | 1167 +++++++++++++++++ tests/firmware_bot/run_tests.py | 1 + tests/firmware_bot/test_firmware_bot.cpp | 161 ++- 3 files changed, 1328 insertions(+), 1 deletion(-) create mode 100644 patches/meshcore/0006-Add-bot-prefs-and-cli.patch diff --git a/patches/meshcore/0006-Add-bot-prefs-and-cli.patch b/patches/meshcore/0006-Add-bot-prefs-and-cli.patch new file mode 100644 index 0000000..72ac236 --- /dev/null +++ b/patches/meshcore/0006-Add-bot-prefs-and-cli.patch @@ -0,0 +1,1167 @@ +diff --git a/examples/companion_radio/BotCommands.cpp b/examples/companion_radio/BotCommands.cpp +index 5b13fbfe..2c206a9f 100644 +--- a/examples/companion_radio/BotCommands.cpp ++++ b/examples/companion_radio/BotCommands.cpp +@@ -159,8 +159,11 @@ BotCommandResult executeCommand(const BotCommand& command, const BotCommandConte + (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); ++ return writeFormatted(output, output_len, "Channels: %s %s emergency=%s public=%s (%u configured)", ++ context.bot_channel[0] ? context.bot_channel : "#bot", ++ 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_UNKNOWN: + return writeText(output, output_len, "Unknown command. Try !help"); + default: +diff --git a/examples/companion_radio/BotPolicy.cpp b/examples/companion_radio/BotPolicy.cpp +index 1cf75c73..66d338ef 100644 +--- a/examples/companion_radio/BotPolicy.cpp ++++ b/examples/companion_radio/BotPolicy.cpp +@@ -49,6 +49,15 @@ BotChannelKind classifyChannel(const char* name, size_t len, bool direct_message + return BOT_CHANNEL_OTHER; + } + ++BotChannelKind classifyChannel(const char* name, size_t len, bool direct_message, const BotPrefs& prefs) { ++ if (direct_message) return BOT_CHANNEL_DM; ++ if (equalsExact(name, len, prefs.public_channel)) return BOT_CHANNEL_PUBLIC; ++ if (equalsIgnoreCase(name, len, prefs.bot_channel)) return BOT_CHANNEL_BOT; ++ if (equalsIgnoreCase(name, len, prefs.testing_channel)) return BOT_CHANNEL_TESTING; ++ if (equalsExact(name, len, prefs.emergency_channel)) return BOT_CHANNEL_EMERGENCY; ++ return BOT_CHANNEL_OTHER; ++} ++ + BotPolicyDecision decide(BotChannelKind kind) { + if (kind == BOT_CHANNEL_DM || kind == BOT_CHANNEL_BOT || kind == BOT_CHANNEL_TESTING) { + return BOT_POLICY_ALLOW_NORMAL; +diff --git a/examples/companion_radio/BotPolicy.h b/examples/companion_radio/BotPolicy.h +index e074e3eb..418ae3ac 100644 +--- a/examples/companion_radio/BotPolicy.h ++++ b/examples/companion_radio/BotPolicy.h +@@ -5,6 +5,7 @@ + namespace BotPolicy { + + BotChannelKind classifyChannel(const char* name, size_t len, bool direct_message); ++BotChannelKind classifyChannel(const char* name, size_t len, bool direct_message, const BotPrefs& prefs); + BotPolicyDecision decide(BotChannelKind kind); + bool isNormalAllowed(BotChannelKind kind); + bool isEmergency(BotChannelKind kind); +diff --git a/examples/companion_radio/BotPrefs.cpp b/examples/companion_radio/BotPrefs.cpp +new file mode 100644 +index 00000000..1d0810a8 +--- /dev/null ++++ b/examples/companion_radio/BotPrefs.cpp +@@ -0,0 +1,389 @@ ++#include "BotPrefs.h" ++ ++#include ++#include ++ ++namespace { ++ ++const size_t SERIALIZED_SIZE = BOT_PREFS_SERIALIZED_SIZE; ++ ++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; ++} ++ ++void copyString(char* dest, size_t dest_len, const char* src) { ++ if (!dest || dest_len == 0) return; ++ size_t len = boundedStrLen(src, dest_len - 1); ++ if (len > 0) memcpy(dest, src, len); ++ dest[len] = 0; ++} ++ ++bool channelNameEqual(const char* lhs, const char* rhs) { ++ size_t lhs_len = boundedStrLen(lhs, BOT_MAX_CHANNEL_NAME_LEN + 1); ++ size_t rhs_len = boundedStrLen(rhs, BOT_MAX_CHANNEL_NAME_LEN + 1); ++ if (lhs_len != rhs_len || lhs_len == 0) return false; ++ return memcmp(lhs, rhs, lhs_len) == 0; ++} ++ ++bool channelHasShape(const char* value, bool public_channel) { ++ size_t len = boundedStrLen(value, BOT_MAX_CHANNEL_NAME_LEN + 1); ++ if (len == 0 || len > BOT_MAX_CHANNEL_NAME_LEN) return false; ++ if (public_channel) return value[0] != '#'; ++ return value[0] == '#' && len > 1; ++} ++ ++uint32_t checksumBytes(const uint8_t* data, size_t len) { ++ uint32_t hash = 2166136261UL; ++ for (size_t i = 0; i < len; i++) { ++ hash ^= data[i]; ++ hash *= 16777619UL; ++ } ++ return hash; ++} ++ ++void put8(uint8_t* data, size_t& pos, uint8_t value) { ++ data[pos++] = value; ++} ++ ++void put16(uint8_t* data, size_t& pos, uint16_t value) { ++ data[pos++] = (uint8_t)(value & 0xFF); ++ data[pos++] = (uint8_t)(value >> 8); ++} ++ ++void put32(uint8_t* data, size_t& pos, uint32_t value) { ++ data[pos++] = (uint8_t)(value & 0xFF); ++ data[pos++] = (uint8_t)((value >> 8) & 0xFF); ++ data[pos++] = (uint8_t)((value >> 16) & 0xFF); ++ data[pos++] = (uint8_t)((value >> 24) & 0xFF); ++} ++ ++uint8_t get8(const uint8_t* data, size_t& pos) { ++ return data[pos++]; ++} ++ ++uint16_t get16(const uint8_t* data, size_t& pos) { ++ uint16_t value = data[pos]; ++ value |= ((uint16_t)data[pos + 1]) << 8; ++ pos += 2; ++ return value; ++} ++ ++uint32_t get32(const uint8_t* data, size_t& pos) { ++ uint32_t value = data[pos]; ++ value |= ((uint32_t)data[pos + 1]) << 8; ++ value |= ((uint32_t)data[pos + 2]) << 16; ++ value |= ((uint32_t)data[pos + 3]) << 24; ++ pos += 4; ++ return value; ++} ++ ++void putFixedString(uint8_t* data, size_t& pos, const char* value, size_t fixed_len) { ++ memset(&data[pos], 0, fixed_len); ++ size_t len = boundedStrLen(value, fixed_len); ++ if (len > 0) memcpy(&data[pos], value, len); ++ pos += fixed_len; ++} ++ ++void getFixedString(const uint8_t* data, size_t& pos, char* value, size_t fixed_len) { ++ memcpy(value, &data[pos], fixed_len); ++ value[fixed_len - 1] = 0; ++ pos += fixed_len; ++} ++ ++int hexValue(char c) { ++ if (c >= '0' && c <= '9') return c - '0'; ++ if (c >= 'a' && c <= 'f') return c - 'a' + 10; ++ if (c >= 'A' && c <= 'F') return c - 'A' + 10; ++ return -1; ++} ++ ++bool sameKeyPrefix(const uint8_t lhs[BOT_SENDER_KEY_PREFIX_LEN], const uint8_t rhs[BOT_SENDER_KEY_PREFIX_LEN]) { ++ return memcmp(lhs, rhs, BOT_SENDER_KEY_PREFIX_LEN) == 0; ++} ++ ++} ++ ++namespace BotPrefsCodec { ++ ++size_t serializedSize() { ++ return SERIALIZED_SIZE; ++} ++ ++void defaults(BotPrefs& prefs) { ++ memset(&prefs, 0, sizeof(prefs)); ++ prefs.enabled = true; ++ prefs.normal_delay_ms = BOT_RESPONSE_DELAY_BASE_MILLIS; ++ prefs.normal_jitter_ms = BOT_RESPONSE_DELAY_JITTER_MILLIS; ++ prefs.local_advert_interval_ms = BOT_PREFS_DEFAULT_LOCAL_ADVERT_MILLIS; ++ prefs.flood_advert_interval_ms = BOT_PREFS_DEFAULT_FLOOD_ADVERT_MILLIS; ++ prefs.command_mask = BOT_COMMAND_MASK_ALL; ++ prefs.max_response_parts = BOT_EMERGENCY_MAX_PARTS; ++ copyString(prefs.bot_channel, sizeof(prefs.bot_channel), "#bot"); ++ copyString(prefs.testing_channel, sizeof(prefs.testing_channel), "#testing"); ++ copyString(prefs.emergency_channel, sizeof(prefs.emergency_channel), "#emergency"); ++ copyString(prefs.public_channel, sizeof(prefs.public_channel), "Public"); ++} ++ ++void validate(BotPrefs& prefs) { ++ prefs.normal_delay_ms = prefs.normal_delay_ms > BOT_PREFS_MAX_DELAY_MILLIS ? BOT_PREFS_MAX_DELAY_MILLIS : prefs.normal_delay_ms; ++ prefs.normal_jitter_ms = prefs.normal_jitter_ms > BOT_PREFS_MAX_DELAY_MILLIS ? BOT_PREFS_MAX_DELAY_MILLIS : prefs.normal_jitter_ms; ++ if (prefs.local_advert_interval_ms > BOT_PREFS_MAX_ADVERT_MILLIS) prefs.local_advert_interval_ms = BOT_PREFS_MAX_ADVERT_MILLIS; ++ if (prefs.flood_advert_interval_ms > BOT_PREFS_MAX_ADVERT_MILLIS) prefs.flood_advert_interval_ms = BOT_PREFS_MAX_ADVERT_MILLIS; ++ prefs.command_mask &= BOT_COMMAND_MASK_ALL; ++ if (prefs.max_response_parts == 0 || prefs.max_response_parts > BOT_EMERGENCY_MAX_PARTS) { ++ prefs.max_response_parts = BOT_EMERGENCY_MAX_PARTS; ++ } ++ if (!channelHasShape(prefs.bot_channel, false)) copyString(prefs.bot_channel, sizeof(prefs.bot_channel), "#bot"); ++ if (!channelHasShape(prefs.testing_channel, false)) copyString(prefs.testing_channel, sizeof(prefs.testing_channel), "#testing"); ++ if (!channelHasShape(prefs.emergency_channel, false)) copyString(prefs.emergency_channel, sizeof(prefs.emergency_channel), "#emergency"); ++ if (!channelHasShape(prefs.public_channel, true)) copyString(prefs.public_channel, sizeof(prefs.public_channel), "Public"); ++ prefs.bot_channel[BOT_MAX_CHANNEL_NAME_LEN] = 0; ++ prefs.testing_channel[BOT_MAX_CHANNEL_NAME_LEN] = 0; ++ prefs.emergency_channel[BOT_MAX_CHANNEL_NAME_LEN] = 0; ++ prefs.public_channel[BOT_MAX_CHANNEL_NAME_LEN] = 0; ++ if (!channelConfigValid(prefs)) { ++ copyString(prefs.bot_channel, sizeof(prefs.bot_channel), "#bot"); ++ copyString(prefs.testing_channel, sizeof(prefs.testing_channel), "#testing"); ++ copyString(prefs.emergency_channel, sizeof(prefs.emergency_channel), "#emergency"); ++ copyString(prefs.public_channel, sizeof(prefs.public_channel), "Public"); ++ } ++ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { ++ prefs.known_bots[i].flags &= BOT_KNOWN_BOT_FLAG_SUPPRESS_NORMAL; ++ prefs.known_bots[i].label[BOT_KNOWN_BOT_LABEL_LEN - 1] = 0; ++ } ++} ++ ++bool channelNameValid(const char* value, bool public_channel) { ++ return channelHasShape(value, public_channel); ++} ++ ++bool channelConfigValid(const BotPrefs& prefs) { ++ return channelHasShape(prefs.bot_channel, false) && channelHasShape(prefs.testing_channel, false) && ++ channelHasShape(prefs.emergency_channel, false) && channelHasShape(prefs.public_channel, true) && ++ !channelNameEqual(prefs.bot_channel, prefs.testing_channel) && ++ !channelNameEqual(prefs.bot_channel, prefs.emergency_channel) && ++ !channelNameEqual(prefs.testing_channel, prefs.emergency_channel) && ++ !channelNameEqual(prefs.bot_channel, prefs.public_channel) && ++ !channelNameEqual(prefs.testing_channel, prefs.public_channel) && ++ !channelNameEqual(prefs.emergency_channel, prefs.public_channel); ++} ++ ++bool serialize(const BotPrefs& prefs, uint8_t* output, size_t output_len) { ++ if (!output || output_len < SERIALIZED_SIZE) return false; ++ ++ BotPrefs clean = prefs; ++ validate(clean); ++ ++ memset(output, 0, output_len); ++ size_t pos = 0; ++ put32(output, pos, BOT_PREFS_MAGIC); ++ put16(output, pos, BOT_PREFS_VERSION); ++ put16(output, pos, (uint16_t)SERIALIZED_SIZE); ++ size_t checksum_pos = pos; ++ put32(output, pos, 0); ++ put8(output, pos, clean.enabled ? 1 : 0); ++ put16(output, pos, clean.normal_delay_ms); ++ put16(output, pos, clean.normal_jitter_ms); ++ put32(output, pos, clean.local_advert_interval_ms); ++ put32(output, pos, clean.flood_advert_interval_ms); ++ put32(output, pos, clean.command_mask); ++ put8(output, pos, clean.max_response_parts); ++ putFixedString(output, pos, clean.bot_channel, BOT_MAX_CHANNEL_NAME_LEN + 1); ++ putFixedString(output, pos, clean.testing_channel, BOT_MAX_CHANNEL_NAME_LEN + 1); ++ putFixedString(output, pos, clean.emergency_channel, BOT_MAX_CHANNEL_NAME_LEN + 1); ++ putFixedString(output, pos, clean.public_channel, BOT_MAX_CHANNEL_NAME_LEN + 1); ++ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { ++ put8(output, pos, clean.known_bots[i].active ? 1 : 0); ++ memcpy(&output[pos], clean.known_bots[i].key_prefix, BOT_SENDER_KEY_PREFIX_LEN); ++ pos += BOT_SENDER_KEY_PREFIX_LEN; ++ put8(output, pos, clean.known_bots[i].flags); ++ putFixedString(output, pos, clean.known_bots[i].label, BOT_KNOWN_BOT_LABEL_LEN); ++ } ++ put32(output, pos, clean.prefs_load_failures); ++ put32(output, pos, clean.prefs_save_failures); ++ ++ if (pos != SERIALIZED_SIZE) return false; ++ uint32_t checksum = checksumBytes(&output[12], SERIALIZED_SIZE - 12); ++ size_t write_pos = checksum_pos; ++ put32(output, write_pos, checksum); ++ return true; ++} ++ ++bool deserialize(const uint8_t* data, size_t data_len, BotPrefs& prefs) { ++ if (!data || data_len != SERIALIZED_SIZE) { ++ defaults(prefs); ++ return false; ++ } ++ ++ size_t pos = 0; ++ uint32_t magic = get32(data, pos); ++ uint16_t version = get16(data, pos); ++ uint16_t length = get16(data, pos); ++ uint32_t checksum = get32(data, pos); ++ if (magic != BOT_PREFS_MAGIC || version != BOT_PREFS_VERSION || length != SERIALIZED_SIZE) { ++ defaults(prefs); ++ return false; ++ } ++ if (checksumBytes(&data[12], SERIALIZED_SIZE - 12) != checksum) { ++ defaults(prefs); ++ return false; ++ } ++ ++ BotPrefs loaded; ++ memset(&loaded, 0, sizeof(loaded)); ++ loaded.enabled = get8(data, pos) != 0; ++ loaded.normal_delay_ms = get16(data, pos); ++ loaded.normal_jitter_ms = get16(data, pos); ++ loaded.local_advert_interval_ms = get32(data, pos); ++ loaded.flood_advert_interval_ms = get32(data, pos); ++ loaded.command_mask = get32(data, pos); ++ loaded.max_response_parts = get8(data, pos); ++ getFixedString(data, pos, loaded.bot_channel, sizeof(loaded.bot_channel)); ++ getFixedString(data, pos, loaded.testing_channel, sizeof(loaded.testing_channel)); ++ getFixedString(data, pos, loaded.emergency_channel, sizeof(loaded.emergency_channel)); ++ getFixedString(data, pos, loaded.public_channel, sizeof(loaded.public_channel)); ++ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { ++ loaded.known_bots[i].active = get8(data, pos) != 0; ++ memcpy(loaded.known_bots[i].key_prefix, &data[pos], BOT_SENDER_KEY_PREFIX_LEN); ++ pos += BOT_SENDER_KEY_PREFIX_LEN; ++ loaded.known_bots[i].flags = get8(data, pos); ++ getFixedString(data, pos, loaded.known_bots[i].label, sizeof(loaded.known_bots[i].label)); ++ } ++ loaded.prefs_load_failures = get32(data, pos); ++ loaded.prefs_save_failures = get32(data, pos); ++ if (pos != SERIALIZED_SIZE) { ++ defaults(prefs); ++ return false; ++ } ++ ++ validate(loaded); ++ prefs = loaded; ++ return true; ++} ++ ++uint32_t commandMaskFor(BotCommandId command_id) { ++ if (command_id <= BOT_COMMAND_NONE || command_id > BOT_COMMAND_UNKNOWN) return 0; ++ return 1UL << command_id; ++} ++ ++bool commandEnabled(const BotPrefs& prefs, BotCommandId command_id) { ++ uint32_t mask = commandMaskFor(command_id); ++ return mask != 0 && (prefs.command_mask & mask) != 0; ++} ++ ++void setCommandEnabled(BotPrefs& prefs, BotCommandId command_id, bool enabled) { ++ uint32_t mask = commandMaskFor(command_id); ++ if (mask == 0) return; ++ if (enabled) { ++ prefs.command_mask |= mask; ++ } else { ++ prefs.command_mask &= ~mask; ++ } ++ validate(prefs); ++} ++ ++const char* commandName(BotCommandId command_id) { ++ switch (command_id) { ++ case BOT_COMMAND_HELP: return "help"; ++ case BOT_COMMAND_PING: return "ping"; ++ case BOT_COMMAND_TEST: return "test"; ++ case BOT_COMMAND_HELLO: return "hello"; ++ case BOT_COMMAND_ABOUT: return "about"; ++ case BOT_COMMAND_DICE: return "roll"; ++ case BOT_COMMAND_STATUS: return "status"; ++ case BOT_COMMAND_CHANNELS: return "channels"; ++ case BOT_COMMAND_UNKNOWN: return "unknown"; ++ default: return ""; ++ } ++} ++ ++bool commandIdForName(const char* name, BotCommandId* command_id) { ++ if (!name || !command_id) return false; ++ 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) { ++ *command_id = (BotCommandId)id; ++ return true; ++ } ++ } ++ return false; ++} ++ ++bool parseKeyPrefixHex(const char* text, uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]) { ++ if (!text || !key_prefix) return false; ++ for (size_t i = 0; i < BOT_SENDER_KEY_PREFIX_LEN; i++) { ++ int high = hexValue(text[i * 2]); ++ int low = hexValue(text[i * 2 + 1]); ++ if (high < 0 || low < 0) return false; ++ key_prefix[i] = (uint8_t)((high << 4) | low); ++ } ++ return text[BOT_SENDER_KEY_PREFIX_LEN * 2] == 0; ++} ++ ++void formatKeyPrefixHex(const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], char* output, size_t output_len) { ++ static const char hex[] = "0123456789abcdef"; ++ if (!output || output_len == 0) return; ++ if (!key_prefix || output_len < BOT_SENDER_KEY_PREFIX_LEN * 2 + 1) { ++ output[0] = 0; ++ return; ++ } ++ for (size_t i = 0; i < BOT_SENDER_KEY_PREFIX_LEN; i++) { ++ output[i * 2] = hex[key_prefix[i] >> 4]; ++ output[i * 2 + 1] = hex[key_prefix[i] & 0x0F]; ++ } ++ output[BOT_SENDER_KEY_PREFIX_LEN * 2] = 0; ++} ++ ++const BotKnownBotEntry* findKnownBot(const BotPrefs& prefs, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]) { ++ if (!key_prefix) return NULL; ++ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { ++ if (prefs.known_bots[i].active && sameKeyPrefix(prefs.known_bots[i].key_prefix, key_prefix)) return &prefs.known_bots[i]; ++ } ++ return NULL; ++} ++ ++bool addKnownBot(BotPrefs& prefs, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], uint8_t flags, ++ const char* label) { ++ if (!key_prefix) return false; ++ size_t slot = BOT_KNOWN_BOT_SLOTS; ++ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { ++ if (prefs.known_bots[i].active && sameKeyPrefix(prefs.known_bots[i].key_prefix, key_prefix)) { ++ slot = i; ++ break; ++ } ++ if (slot == BOT_KNOWN_BOT_SLOTS && !prefs.known_bots[i].active) slot = i; ++ } ++ if (slot == BOT_KNOWN_BOT_SLOTS) return false; ++ ++ prefs.known_bots[slot].active = true; ++ memcpy(prefs.known_bots[slot].key_prefix, key_prefix, BOT_SENDER_KEY_PREFIX_LEN); ++ prefs.known_bots[slot].flags = flags & BOT_KNOWN_BOT_FLAG_SUPPRESS_NORMAL; ++ copyString(prefs.known_bots[slot].label, sizeof(prefs.known_bots[slot].label), label ? label : "bot"); ++ return true; ++} ++ ++bool removeKnownBot(BotPrefs& prefs, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]) { ++ if (!key_prefix) return false; ++ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { ++ if (prefs.known_bots[i].active && sameKeyPrefix(prefs.known_bots[i].key_prefix, key_prefix)) { ++ memset(&prefs.known_bots[i], 0, sizeof(prefs.known_bots[i])); ++ return true; ++ } ++ } ++ return false; ++} ++ ++} +diff --git a/examples/companion_radio/BotPrefs.h b/examples/companion_radio/BotPrefs.h +new file mode 100644 +index 00000000..676f7de2 +--- /dev/null ++++ b/examples/companion_radio/BotPrefs.h +@@ -0,0 +1,31 @@ ++#pragma once ++ ++#include "BotTypes.h" ++ ++#include ++#include ++ ++namespace BotPrefsCodec { ++ ++size_t serializedSize(); ++void defaults(BotPrefs& prefs); ++void validate(BotPrefs& prefs); ++bool serialize(const BotPrefs& prefs, uint8_t* output, size_t output_len); ++bool deserialize(const uint8_t* data, size_t data_len, BotPrefs& prefs); ++bool channelNameValid(const char* value, bool public_channel); ++bool channelConfigValid(const BotPrefs& prefs); ++ ++uint32_t commandMaskFor(BotCommandId command_id); ++bool commandEnabled(const BotPrefs& prefs, BotCommandId command_id); ++void setCommandEnabled(BotPrefs& prefs, BotCommandId command_id, bool enabled); ++const char* commandName(BotCommandId command_id); ++bool commandIdForName(const char* name, BotCommandId* command_id); ++ ++bool parseKeyPrefixHex(const char* text, uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]); ++void formatKeyPrefixHex(const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], char* output, size_t output_len); ++bool addKnownBot(BotPrefs& prefs, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], uint8_t flags, ++ const char* label); ++bool removeKnownBot(BotPrefs& prefs, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]); ++const BotKnownBotEntry* findKnownBot(const BotPrefs& prefs, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]); ++ ++} +diff --git a/examples/companion_radio/BotTypes.h b/examples/companion_radio/BotTypes.h +index d44aadb2..98d4a583 100644 +--- a/examples/companion_radio/BotTypes.h ++++ b/examples/companion_radio/BotTypes.h +@@ -27,6 +27,27 @@ + #define BOT_KNOWN_BOT_FLAG_SUPPRESS_NORMAL 0x01 + #define BOT_SENDER_KEY_PREFIX_LEN 6 + #define BOT_MIN_AUTH_SENDER_KEY_PREFIX_LEN 4 ++#define BOT_KNOWN_BOT_LABEL_LEN 12 ++#define BOT_PREFS_MAGIC 0x31504642UL ++#define BOT_PREFS_VERSION 1 ++#define BOT_PREFS_DEFAULT_LOCAL_ADVERT_MILLIS (24UL * 60UL * 60UL * 1000UL) ++#define BOT_PREFS_DEFAULT_FLOOD_ADVERT_MILLIS (24UL * 60UL * 60UL * 1000UL) ++#define BOT_PREFS_INITIAL_LOCAL_ADVERT_MILLIS 60000UL ++#define BOT_PREFS_MAX_DELAY_MILLIS 60000U ++#define BOT_PREFS_MAX_ADVERT_MILLIS (7UL * 24UL * 60UL * 60UL * 1000UL) ++#define BOT_COMMAND_MASK_HELP (1UL << BOT_COMMAND_HELP) ++#define BOT_COMMAND_MASK_PING (1UL << BOT_COMMAND_PING) ++#define BOT_COMMAND_MASK_TEST (1UL << BOT_COMMAND_TEST) ++#define BOT_COMMAND_MASK_HELLO (1UL << BOT_COMMAND_HELLO) ++#define BOT_COMMAND_MASK_ABOUT (1UL << BOT_COMMAND_ABOUT) ++#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_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) ++#define BOT_PREFS_SERIALIZED_SIZE 294 + + enum BotChannelKind : uint8_t { + BOT_CHANNEL_DM = 0, +@@ -116,6 +137,10 @@ struct BotResponse { + + struct BotCommandContext { + char node_name[BOT_MAX_SENDER_NAME_LEN + 1]; ++ char bot_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; ++ 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]; + uint32_t uptime_seconds; + uint16_t battery_millivolts; + uint32_t storage_used_kb; +@@ -143,7 +168,7 @@ struct BotKnownBotEntry { + bool active; + uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]; + uint8_t flags; +- char label[12]; ++ char label[BOT_KNOWN_BOT_LABEL_LEN]; + }; + + struct BotCoordinatorPending { +@@ -178,11 +203,17 @@ struct BotPrefs { + bool enabled; + uint16_t normal_delay_ms; + uint16_t normal_jitter_ms; ++ uint32_t local_advert_interval_ms; ++ uint32_t flood_advert_interval_ms; ++ uint32_t command_mask; + uint8_t max_response_parts; + char bot_channel[BOT_MAX_CHANNEL_NAME_LEN + 1]; + 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]; ++ BotKnownBotEntry known_bots[BOT_KNOWN_BOT_SLOTS]; ++ uint32_t prefs_load_failures; ++ uint32_t prefs_save_failures; + }; + + struct BotStats { +@@ -204,7 +235,7 @@ struct BotStats { + static_assert(sizeof(BotMessage) <= 248, "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(BotCommandContext) <= 192, "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"); +@@ -212,5 +243,5 @@ static_assert(sizeof(BotCoordinatorPending) <= 32, "BotCoordinatorPending RAM bu + static_assert(sizeof(BotCoordinatorRecent) <= 24, "BotCoordinatorRecent RAM budget exceeded"); + static_assert(sizeof(BotCoordinatorReady) <= 24, "BotCoordinatorReady RAM budget exceeded"); + static_assert(sizeof(BotEmergencyForward) <= 480, "BotEmergencyForward RAM budget exceeded"); +-static_assert(sizeof(BotPrefs) <= 128, "BotPrefs RAM budget exceeded"); ++static_assert(sizeof(BotPrefs) <= 320, "BotPrefs RAM budget exceeded"); + static_assert(sizeof(BotStats) <= 64, "BotStats RAM budget exceeded"); +diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp +index c7988bb3..9a134389 100644 +--- a/examples/companion_radio/DataStore.cpp ++++ b/examples/companion_radio/DataStore.cpp +@@ -1,6 +1,10 @@ + #include + #include "DataStore.h" + ++#if CMESH_BOT_ENABLED ++#include "BotPrefs.h" ++#endif ++ + #if defined(EXTRAFS) || defined(QSPIFLASH) + #define MAX_BLOBRECS 100 + #else +@@ -199,6 +203,38 @@ void DataStore::loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon) + } + } + ++#if CMESH_BOT_ENABLED ++bool DataStore::loadBotPrefs(BotPrefs& prefs) { ++ if (!_fs->exists("/bot_prefs_v1")) { ++ BotPrefsCodec::defaults(prefs); ++ return false; ++ } ++ ++ uint8_t data[BOT_PREFS_SERIALIZED_SIZE]; ++ File file = openRead(_fs, "/bot_prefs_v1"); ++ bool success = false; ++ if (file) { ++ success = file.size() == sizeof(data) && file.read(data, sizeof(data)) == sizeof(data) && ++ BotPrefsCodec::deserialize(data, sizeof(data), prefs); ++ file.close(); ++ } ++ if (!success) BotPrefsCodec::defaults(prefs); ++ return success; ++} ++ ++bool DataStore::saveBotPrefs(const BotPrefs& prefs) { ++ uint8_t data[BOT_PREFS_SERIALIZED_SIZE]; ++ if (!BotPrefsCodec::serialize(prefs, data, sizeof(data))) return false; ++ ++ File file = openWrite(_fs, "/bot_prefs_v1"); ++ if (!file) return false; ++ bool success = file.write(data, sizeof(data)) == sizeof(data); ++ file.close(); ++ if (!success) _fs->remove("/bot_prefs_v1"); ++ return success; ++} ++#endif ++ + void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& node_lat, double& node_lon) { + File file = openRead(_fs, filename); + if (file) { +diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h +index 58b4d5d2..187cd771 100644 +--- a/examples/companion_radio/DataStore.h ++++ b/examples/companion_radio/DataStore.h +@@ -5,6 +5,13 @@ + #include + #include "NodePrefs.h" + ++#ifndef CMESH_BOT_ENABLED ++#define CMESH_BOT_ENABLED 0 ++#endif ++#if CMESH_BOT_ENABLED ++#include "BotTypes.h" ++#endif ++ + class DataStoreHost { + public: + virtual bool onContactLoaded(const ContactInfo& contact) =0; +@@ -35,6 +42,10 @@ public: + bool saveMainIdentity(const mesh::LocalIdentity &identity); + void loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon); + void savePrefs(const NodePrefs& prefs, double node_lat, double node_lon); ++#if CMESH_BOT_ENABLED ++ bool loadBotPrefs(BotPrefs& prefs); ++ bool saveBotPrefs(const BotPrefs& prefs); ++#endif + void loadContacts(DataStoreHost* host); + void saveContacts(DataStoreHost* host); + void loadChannels(DataStoreHost* host); +diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp +index 31e9bad8..13ead485 100644 +--- a/examples/companion_radio/MyMesh.cpp ++++ b/examples/companion_radio/MyMesh.cpp +@@ -2,10 +2,13 @@ + + #include // needed for PlatformIO + #include ++#include ++#include + + #if CMESH_BOT_ENABLED + #include "BotCommands.h" + #include "BotPolicy.h" ++#include "BotPrefs.h" + #include "EmergencyForwarder.h" + #include "FirmwareBot.h" + #include "KnownBotRegistry.h" +@@ -115,8 +118,6 @@ + #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==" +@@ -127,6 +128,49 @@ static size_t botBoundedStrLen(const char *value, size_t max_len) { + while (value && len < max_len && value[len] != 0) len++; + return len; + } ++ ++static void botCopyString(char *dest, size_t dest_len, const char *src) { ++ if (!dest || dest_len == 0) return; ++ size_t len = botBoundedStrLen(src, dest_len - 1); ++ if (len > 0) memcpy(dest, src, len); ++ dest[len] = 0; ++} ++ ++static bool botParseU32(const char *text, uint32_t *value, const char **end_out) { ++ if (!text || !value || !isdigit((unsigned char)text[0])) return false; ++ uint32_t parsed = 0; ++ while (isdigit((unsigned char)*text)) { ++ uint32_t next = parsed * 10UL + (uint32_t)(*text - '0'); ++ if (next < parsed) return false; ++ parsed = next; ++ text++; ++ } ++ *value = parsed; ++ if (end_out) *end_out = text; ++ return true; ++} ++ ++static void botSkipSpaces(const char **text) { ++ while (text && *text && **text == ' ') (*text)++; ++} ++ ++static bool botReadToken(const char **text, char *output, size_t output_len) { ++ if (!text || !*text || !output || output_len == 0) return false; ++ botSkipSpaces(text); ++ const char *start = *text; ++ size_t len = 0; ++ while (start[len] != 0 && start[len] != ' ') len++; ++ if (len == 0 || len + 1 > output_len) return false; ++ memcpy(output, start, len); ++ output[len] = 0; ++ *text = start + len; ++ return true; ++} ++ ++static bool botNoMoreTokens(const char *text) { ++ botSkipSpaces(&text); ++ return text && *text == 0; ++} + #endif + + // these are _pushed_ to client app at any time +@@ -559,11 +603,226 @@ void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uin + } + + #if CMESH_BOT_ENABLED ++void MyMesh::applyBotPrefs() { ++ BotPrefsCodec::validate(bot_prefs); ++ KnownBotRegistry::clear(known_bot_entries, BOT_KNOWN_BOT_SLOTS); ++ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { ++ if (bot_prefs.known_bots[i].active) { ++ KnownBotRegistry::add(known_bot_entries, BOT_KNOWN_BOT_SLOTS, bot_prefs.known_bots[i].key_prefix, ++ bot_prefs.known_bots[i].flags, bot_prefs.known_bots[i].label); ++ } ++ } ++ if (bot_prefs.enabled) { ++ scheduleBotLocalAdvert(bot_prefs.local_advert_interval_ms ? BOT_PREFS_INITIAL_LOCAL_ADVERT_MILLIS : 0); ++ scheduleBotFloodAdvert(bot_prefs.flood_advert_interval_ms); ++ } else { ++ scheduleBotLocalAdvert(0); ++ scheduleBotFloodAdvert(0); ++ } ++} ++ ++bool MyMesh::saveBotPrefs() { ++ BotPrefsCodec::validate(bot_prefs); ++ bool success = _store->saveBotPrefs(bot_prefs); ++ if (!success) bot_prefs.prefs_save_failures++; ++ return success; ++} ++ ++static void printBotPrefsSaveResult(const char *success_message, bool saved) { ++ Serial.println(saved ? success_message : " Error: bot prefs save failed"); ++} ++ ++void MyMesh::printBotPrefs() { ++ Serial.printf(" > bot %s\n", bot_prefs.enabled ? "enabled" : "disabled"); ++ Serial.printf(" > channels bot=%s testing=%s emergency=%s public=%s\n", bot_prefs.bot_channel, ++ bot_prefs.testing_channel, bot_prefs.emergency_channel, bot_prefs.public_channel); ++ Serial.printf(" > delay base=%u jitter=%u\n", (unsigned)bot_prefs.normal_delay_ms, ++ (unsigned)bot_prefs.normal_jitter_ms); ++ Serial.printf(" > advert local=%lu flood=%lu\n", (unsigned long)bot_prefs.local_advert_interval_ms, ++ (unsigned long)bot_prefs.flood_advert_interval_ms); ++} ++ ++bool MyMesh::handleBotCLI(const char *args) { ++ if (!args) return false; ++ botSkipSpaces(&args); ++ if (*args == 0) { ++ printBotPrefs(); ++ return true; ++ } ++ if (strcmp(args, "enable") == 0) { ++ bot_prefs.enabled = true; ++ applyBotPrefs(); ++ printBotPrefsSaveResult(" > bot enabled", saveBotPrefs()); ++ return true; ++ } ++ if (strcmp(args, "disable") == 0) { ++ bot_prefs.enabled = false; ++ ResponseCoordinator::clear(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS); ++ memset(pending_bot_responses, 0, sizeof(pending_bot_responses)); ++ applyBotPrefs(); ++ printBotPrefsSaveResult(" > bot disabled", saveBotPrefs()); ++ return true; ++ } ++ if (strcmp(args, "channels") == 0) { ++ Serial.printf(" > %s %s %s %s\n", bot_prefs.bot_channel, bot_prefs.testing_channel, ++ bot_prefs.emergency_channel, bot_prefs.public_channel); ++ return true; ++ } ++ if (memcmp(args, "channels ", 9) == 0) { ++ const char *pos = args + 9; ++ char bot[BOT_MAX_CHANNEL_NAME_LEN + 1]; ++ char testing[BOT_MAX_CHANNEL_NAME_LEN + 1]; ++ char emergency[BOT_MAX_CHANNEL_NAME_LEN + 1]; ++ char public_name[BOT_MAX_CHANNEL_NAME_LEN + 1]; ++ BotPrefs updated = bot_prefs; ++ if (botReadToken(&pos, bot, sizeof(bot)) && botReadToken(&pos, testing, sizeof(testing)) && ++ botReadToken(&pos, emergency, sizeof(emergency)) && botReadToken(&pos, public_name, sizeof(public_name)) && ++ botNoMoreTokens(pos) && BotPrefsCodec::channelNameValid(bot, false) && ++ BotPrefsCodec::channelNameValid(testing, false) && BotPrefsCodec::channelNameValid(emergency, false) && ++ BotPrefsCodec::channelNameValid(public_name, true)) { ++ botCopyString(updated.bot_channel, sizeof(updated.bot_channel), bot); ++ botCopyString(updated.testing_channel, sizeof(updated.testing_channel), testing); ++ botCopyString(updated.emergency_channel, sizeof(updated.emergency_channel), emergency); ++ botCopyString(updated.public_channel, sizeof(updated.public_channel), public_name); ++ if (BotPrefsCodec::channelConfigValid(updated)) { ++ bot_prefs = updated; ++ printBotPrefsSaveResult(" > bot channels saved", saveBotPrefs()); ++ } else { ++ Serial.println(" Error: duplicate bot channel names"); ++ } ++ } else { ++ Serial.println(" Error: usage bot channels "); ++ } ++ return true; ++ } ++ if (memcmp(args, "delay ", 6) == 0) { ++ const char *pos = args + 6; ++ uint32_t base = 0; ++ uint32_t jitter = 0; ++ if (botParseU32(pos, &base, &pos)) { ++ botSkipSpaces(&pos); ++ if (botParseU32(pos, &jitter, &pos) && *pos == 0 && base <= BOT_PREFS_MAX_DELAY_MILLIS && ++ jitter <= BOT_PREFS_MAX_DELAY_MILLIS) { ++ bot_prefs.normal_delay_ms = (uint16_t)base; ++ bot_prefs.normal_jitter_ms = (uint16_t)jitter; ++ printBotPrefsSaveResult(" > bot delay saved", saveBotPrefs()); ++ } else { ++ Serial.println(" Error: usage bot delay "); ++ } ++ } else { ++ Serial.println(" Error: usage bot delay "); ++ } ++ return true; ++ } ++ if (memcmp(args, "advert ", 7) == 0) { ++ const char *pos = args + 7; ++ uint32_t local = 0; ++ uint32_t flood = 0; ++ if (botParseU32(pos, &local, &pos)) { ++ botSkipSpaces(&pos); ++ if (botParseU32(pos, &flood, &pos) && *pos == 0 && local <= BOT_PREFS_MAX_ADVERT_MILLIS && ++ flood <= BOT_PREFS_MAX_ADVERT_MILLIS) { ++ bot_prefs.local_advert_interval_ms = local; ++ bot_prefs.flood_advert_interval_ms = flood; ++ applyBotPrefs(); ++ printBotPrefsSaveResult(" > bot advert saved", saveBotPrefs()); ++ } else { ++ Serial.println(" Error: usage bot advert "); ++ } ++ } else { ++ Serial.println(" Error: usage bot advert "); ++ } ++ return true; ++ } ++ if (strcmp(args, "known list") == 0) { ++ char hex[BOT_SENDER_KEY_PREFIX_LEN * 2 + 1]; ++ for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { ++ if (!bot_prefs.known_bots[i].active) continue; ++ BotPrefsCodec::formatKeyPrefixHex(bot_prefs.known_bots[i].key_prefix, hex, sizeof(hex)); ++ Serial.printf(" > %s %s flags=%u\n", hex, bot_prefs.known_bots[i].label, ++ (unsigned)bot_prefs.known_bots[i].flags); ++ } ++ return true; ++ } ++ if (memcmp(args, "known add ", 10) == 0) { ++ const char *pos = args + 10; ++ char key_hex[BOT_SENDER_KEY_PREFIX_LEN * 2 + 1]; ++ char label[BOT_KNOWN_BOT_LABEL_LEN]; ++ label[0] = 0; ++ if (botReadToken(&pos, key_hex, sizeof(key_hex))) { ++ bool have_label = botReadToken(&pos, label, sizeof(label)); ++ uint8_t key[BOT_SENDER_KEY_PREFIX_LEN]; ++ if (botNoMoreTokens(pos) && (!have_label || label[0] != 0) && BotPrefsCodec::parseKeyPrefixHex(key_hex, key) && ++ BotPrefsCodec::addKnownBot(bot_prefs, key, BOT_KNOWN_BOT_FLAG_SUPPRESS_NORMAL, have_label ? label : "bot")) { ++ applyBotPrefs(); ++ printBotPrefsSaveResult(" > known bot saved", saveBotPrefs()); ++ } else { ++ Serial.println(" Error: known bot table full or invalid key"); ++ } ++ } else { ++ Serial.println(" Error: usage bot known add [label]"); ++ } ++ return true; ++ } ++ if (memcmp(args, "known remove ", 13) == 0) { ++ const char *pos = args + 13; ++ char key_hex[BOT_SENDER_KEY_PREFIX_LEN * 2 + 1]; ++ uint8_t key[BOT_SENDER_KEY_PREFIX_LEN]; ++ if (botReadToken(&pos, key_hex, sizeof(key_hex)) && botNoMoreTokens(pos) && ++ BotPrefsCodec::parseKeyPrefixHex(key_hex, key) && BotPrefsCodec::removeKnownBot(bot_prefs, key)) { ++ applyBotPrefs(); ++ printBotPrefsSaveResult(" > known bot removed", saveBotPrefs()); ++ } else { ++ Serial.println(" Error: known bot not found"); ++ } ++ return true; ++ } ++ if (strcmp(args, "commands") == 0) { ++ for (uint8_t id = BOT_COMMAND_HELP; id <= BOT_COMMAND_UNKNOWN; id++) { ++ BotCommandId command_id = (BotCommandId)id; ++ Serial.printf(" > %s %s\n", BotPrefsCodec::commandName(command_id), ++ BotPrefsCodec::commandEnabled(bot_prefs, command_id) ? "enabled" : "disabled"); ++ } ++ return true; ++ } ++ if (memcmp(args, "commands enable ", 16) == 0 || memcmp(args, "commands disable ", 17) == 0) { ++ bool enable = memcmp(args, "commands enable ", 16) == 0; ++ const char *name = args + (enable ? 16 : 17); ++ BotCommandId command_id; ++ if (BotPrefsCodec::commandIdForName(name, &command_id)) { ++ BotPrefsCodec::setCommandEnabled(bot_prefs, command_id, enable); ++ if (saveBotPrefs()) { ++ Serial.printf(" > command %s %s\n", BotPrefsCodec::commandName(command_id), enable ? "enabled" : "disabled"); ++ } else { ++ Serial.println(" Error: bot prefs save failed"); ++ } ++ } else { ++ Serial.println(" Error: unknown bot command"); ++ } ++ return true; ++ } ++ if (strcmp(args, "stats") == 0) { ++ Serial.printf(" > observed=%lu ignored=%lu eligible=%lu sent=%lu failed=%lu suppressed=%lu emergency=%lu/%lu\n", ++ (unsigned long)bot_stats.observed_messages, (unsigned long)bot_stats.ignored_messages, ++ (unsigned long)bot_stats.eligible_messages, (unsigned long)bot_stats.sent_messages, ++ (unsigned long)bot_stats.send_failures, (unsigned long)bot_stats.suppressed_responses, ++ (unsigned long)bot_stats.emergency_forwards, (unsigned long)bot_stats.emergency_forward_failures); ++ Serial.printf(" > prefs load_failures=%lu save_failures=%lu\n", (unsigned long)bot_prefs.prefs_load_failures, ++ (unsigned long)bot_prefs.prefs_save_failures); ++ return true; ++ } ++ if (strcmp(args, "save") == 0) { ++ Serial.println(saveBotPrefs() ? " > bot prefs saved" : " Error: bot prefs save failed"); ++ return true; ++ } ++ return false; ++} ++ + 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); ++ message.channel_kind = BotPolicy::classifyChannel(NULL, 0, true, bot_prefs); + 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); +@@ -580,7 +839,7 @@ void MyMesh::observeBotChannelMessage(uint8_t channel_idx, const char *channel_n + 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); ++ message.channel_kind = BotPolicy::classifyChannel(channel_name, channel_len, false, bot_prefs); + if (channel_name && channel_len > 0) { + memcpy(message.channel_name, channel_name, channel_len); + message.channel_name[channel_len] = 0; +@@ -598,6 +857,10 @@ void MyMesh::observeBotChannelMessage(uint8_t channel_idx, const char *channel_n + 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)); ++ StrHelper::strzcpy(context.bot_channel, bot_prefs.bot_channel, sizeof(context.bot_channel)); ++ StrHelper::strzcpy(context.testing_channel, bot_prefs.testing_channel, sizeof(context.testing_channel)); ++ StrHelper::strzcpy(context.emergency_channel, bot_prefs.emergency_channel, sizeof(context.emergency_channel)); ++ StrHelper::strzcpy(context.public_channel, bot_prefs.public_channel, sizeof(context.public_channel)); + context.uptime_seconds = _ms->getMillis() / 1000; + context.observed_messages = bot_stats.observed_messages; + context.ignored_messages = bot_stats.ignored_messages; +@@ -658,7 +921,7 @@ bool MyMesh::findBotChannel(BotChannelKind kind, uint8_t &channel_idx) { + ChannelDetails channel; + if (getChannel(i, channel)) { + size_t name_len = botBoundedStrLen(channel.name, BOT_MAX_CHANNEL_NAME_LEN); +- if (BotPolicy::classifyChannel(channel.name, name_len, false) == kind) { ++ if (BotPolicy::classifyChannel(channel.name, name_len, false, bot_prefs) == kind) { + channel_idx = i; + return true; + } +@@ -739,6 +1002,11 @@ void MyMesh::recordBotObservation(const BotMessage &message, const ContactInfo * + return; + } + ++ if (!bot_prefs.enabled) { ++ bot_stats.ignored_messages++; ++ return; ++ } ++ + if (observeKnownBotResponse(message, direct_recipient != NULL)) return; + sendQueuedBotResponses(); + +@@ -748,7 +1016,8 @@ void MyMesh::recordBotObservation(const BotMessage &message, const ContactInfo * + return; + } + +- if (FirmwareBot::isCommandOnCooldown(bot_command_cooldowns, BOT_COMMAND_COOLDOWN_SLOTS, command.id, _ms->getMillis())) { ++ if (!BotPrefsCodec::commandEnabled(bot_prefs, command.id) || ++ FirmwareBot::isCommandOnCooldown(bot_command_cooldowns, BOT_COMMAND_COOLDOWN_SLOTS, command.id, _ms->getMillis())) { + bot_stats.ignored_messages++; + return; + } +@@ -773,7 +1042,9 @@ void MyMesh::recordBotObservation(const BotMessage &message, const ContactInfo * + message, command.id, request_fingerprint, + response_fingerprint, _ms->getMillis(), + context.random_seed, bot_identity_seed, +- queue_depth, &fingerprint, &due_at_millis); ++ queue_depth, bot_prefs.normal_delay_ms, ++ bot_prefs.normal_jitter_ms, &fingerprint, ++ &due_at_millis); + if (schedule == BOT_COORDINATOR_NO_SPACE || schedule == BOT_COORDINATOR_NOT_NORMAL) { + bot_stats.send_failures++; + return; +@@ -903,14 +1174,15 @@ void MyMesh::scheduleBotFloodAdvert(unsigned long interval_millis) { + + void MyMesh::tickBot() { + sendQueuedEmergencyForwards(); +- sendQueuedBotResponses(); ++ if (bot_prefs.enabled) sendQueuedBotResponses(); ++ if (!bot_prefs.enabled) return; + if (next_bot_local_advert && millisHasNowPassed(next_bot_local_advert)) { + sendBotSelfAdvert(false); +- scheduleBotLocalAdvert(BOT_AUTO_ADVERT_INTERVAL_MILLIS); ++ scheduleBotLocalAdvert(bot_prefs.local_advert_interval_ms); + } + if (next_bot_flood_advert && millisHasNowPassed(next_bot_flood_advert)) { + sendBotSelfAdvert(true); +- scheduleBotFloodAdvert(BOT_AUTO_ADVERT_INTERVAL_MILLIS); ++ scheduleBotFloodAdvert(bot_prefs.flood_advert_interval_ms); + } + } + #endif +@@ -1244,6 +1516,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe + memset(advert_paths, 0, sizeof(advert_paths)); + memset(send_scope.key, 0, sizeof(send_scope.key)); + #if CMESH_BOT_ENABLED ++ BotPrefsCodec::defaults(bot_prefs); + memset(&bot_stats, 0, sizeof(bot_stats)); + memset(pending_bot_responses, 0, sizeof(pending_bot_responses)); + memset(pending_emergency_forwards, 0, sizeof(pending_emergency_forwards)); +@@ -1360,8 +1633,9 @@ void MyMesh::begin(bool has_display) { + 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); ++ if (!_store->loadBotPrefs(bot_prefs)) bot_prefs.prefs_load_failures++; ++ BotPrefsCodec::validate(bot_prefs); ++ applyBotPrefs(); + #endif + } + +@@ -2382,7 +2656,13 @@ void MyMesh::checkCLIRescueCmd() { + if (len > 0 && cli_command[len - 1] == '\r') { // received complete line + cli_command[len - 1] = 0; // replace newline with C string null terminator + +- if (memcmp(cli_command, "set ", 4) == 0) { ++ if (memcmp(cli_command, "bot", 3) == 0 && (cli_command[3] == 0 || cli_command[3] == ' ')) { ++#if CMESH_BOT_ENABLED ++ if (!handleBotCLI(&cli_command[3])) Serial.println(" Error: unknown bot command"); ++#else ++ Serial.println(" Error: bot support is disabled in this build"); ++#endif ++ } else if (memcmp(cli_command, "set ", 4) == 0) { + const char* config = &cli_command[4]; + if (memcmp(config, "pin ", 4) == 0) { + _prefs.ble_pin = atoi(&config[4]); +diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h +index 698ecad8..6f8d4329 100644 +--- a/examples/companion_radio/MyMesh.h ++++ b/examples/companion_radio/MyMesh.h +@@ -202,6 +202,10 @@ private: + } + + #if CMESH_BOT_ENABLED ++ void applyBotPrefs(); ++ bool saveBotPrefs(); ++ void printBotPrefs(); ++ bool handleBotCLI(const char *args); + void observeBotDirectMessage(const ContactInfo &from, uint32_t sender_timestamp, const uint8_t *sender_prefix, + size_t sender_prefix_len, const char *text); + void observeBotChannelMessage(uint8_t channel_idx, const char *channel_name, const char *text, +@@ -273,6 +277,7 @@ private: + size_t text_len; + }; + ++ BotPrefs bot_prefs; + BotStats bot_stats; + PendingBotResponse pending_bot_responses[BOT_PENDING_RESPONSE_SLOTS]; + PendingEmergencyForward pending_emergency_forwards[BOT_PENDING_EMERGENCY_SLOTS]; +diff --git a/examples/companion_radio/ResponseCoordinator.cpp b/examples/companion_radio/ResponseCoordinator.cpp +index c5c8fc35..74ae3150 100644 +--- a/examples/companion_radio/ResponseCoordinator.cpp ++++ b/examples/companion_radio/ResponseCoordinator.cpp +@@ -60,8 +60,15 @@ void clearRecent(BotCoordinatorRecent recent[], size_t recent_count) { + + uint32_t responseDelayMillis(const BotMessage& message, BotCommandId command_id, BotFingerprint request_fingerprint, + uint32_t bot_identity_seed, uint8_t queue_depth, uint32_t jitter_seed) { +- uint32_t jitter = BOT_RESPONSE_DELAY_JITTER_MILLIS ? jitter_seed % BOT_RESPONSE_DELAY_JITTER_MILLIS : 0; +- return BOT_RESPONSE_DELAY_BASE_MILLIS + channelDelayBias(message.channel_kind) + commandDelayBias(command_id) + ++ return responseDelayMillis(message, command_id, request_fingerprint, bot_identity_seed, queue_depth, jitter_seed, ++ BOT_RESPONSE_DELAY_BASE_MILLIS, BOT_RESPONSE_DELAY_JITTER_MILLIS); ++} ++ ++uint32_t responseDelayMillis(const BotMessage& message, BotCommandId command_id, BotFingerprint request_fingerprint, ++ uint32_t bot_identity_seed, uint8_t queue_depth, uint32_t jitter_seed, ++ uint16_t base_delay_millis, uint16_t jitter_millis) { ++ uint32_t jitter = jitter_millis ? jitter_seed % jitter_millis : 0; ++ return (uint32_t)base_delay_millis + channelDelayBias(message.channel_kind) + commandDelayBias(command_id) + + queueDelayBias(queue_depth) + tieBreakBias(request_fingerprint, bot_identity_seed) + jitter; + } + +@@ -70,11 +77,23 @@ BotCoordinatorScheduleResult schedule(BotCoordinatorPending pending[], size_t pe + BotFingerprint request_fingerprint, BotFingerprint response_fingerprint, + uint32_t now_millis, uint32_t jitter_seed, uint32_t bot_identity_seed, + uint8_t queue_depth, BotFingerprint* fingerprint, uint32_t* due_at_millis) { ++ return schedule(pending, pending_count, message, command_id, request_fingerprint, response_fingerprint, now_millis, ++ jitter_seed, bot_identity_seed, queue_depth, BOT_RESPONSE_DELAY_BASE_MILLIS, ++ BOT_RESPONSE_DELAY_JITTER_MILLIS, fingerprint, due_at_millis); ++} ++ ++BotCoordinatorScheduleResult schedule(BotCoordinatorPending pending[], size_t pending_count, ++ const BotMessage& message, BotCommandId command_id, ++ BotFingerprint request_fingerprint, BotFingerprint response_fingerprint, ++ uint32_t now_millis, uint32_t jitter_seed, uint32_t bot_identity_seed, ++ uint8_t queue_depth, uint16_t base_delay_millis, uint16_t jitter_millis, ++ BotFingerprint* fingerprint, uint32_t* due_at_millis) { + if (fingerprint) fingerprint->value = 0; + if (due_at_millis) *due_at_millis = 0; + if (!pending || pending_count == 0 || !isNormalChannel(message.channel_kind) || request_fingerprint.value == 0 || response_fingerprint.value == 0) return BOT_COORDINATOR_NOT_NORMAL; + +- uint32_t due = now_millis + responseDelayMillis(message, command_id, request_fingerprint, bot_identity_seed, queue_depth, jitter_seed); ++ uint32_t due = now_millis + responseDelayMillis(message, command_id, request_fingerprint, bot_identity_seed, queue_depth, ++ jitter_seed, base_delay_millis, jitter_millis); + size_t slot = pending_count; + + for (size_t i = 0; i < pending_count; i++) { +diff --git a/examples/companion_radio/ResponseCoordinator.h b/examples/companion_radio/ResponseCoordinator.h +index d900633e..7717bee7 100644 +--- a/examples/companion_radio/ResponseCoordinator.h ++++ b/examples/companion_radio/ResponseCoordinator.h +@@ -8,11 +8,20 @@ void clear(BotCoordinatorPending pending[], size_t pending_count); + void clearRecent(BotCoordinatorRecent recent[], size_t recent_count); + uint32_t responseDelayMillis(const BotMessage& message, BotCommandId command_id, BotFingerprint request_fingerprint, + uint32_t bot_identity_seed, uint8_t queue_depth, uint32_t jitter_seed); ++uint32_t responseDelayMillis(const BotMessage& message, BotCommandId command_id, BotFingerprint request_fingerprint, ++ uint32_t bot_identity_seed, uint8_t queue_depth, uint32_t jitter_seed, ++ uint16_t base_delay_millis, uint16_t jitter_millis); + BotCoordinatorScheduleResult schedule(BotCoordinatorPending pending[], size_t pending_count, + const BotMessage& message, BotCommandId command_id, + BotFingerprint request_fingerprint, BotFingerprint response_fingerprint, + uint32_t now_millis, uint32_t jitter_seed, uint32_t bot_identity_seed, + uint8_t queue_depth, BotFingerprint* fingerprint, uint32_t* due_at_millis); ++BotCoordinatorScheduleResult schedule(BotCoordinatorPending pending[], size_t pending_count, ++ const BotMessage& message, BotCommandId command_id, ++ BotFingerprint request_fingerprint, BotFingerprint response_fingerprint, ++ uint32_t now_millis, uint32_t jitter_seed, uint32_t bot_identity_seed, ++ uint8_t queue_depth, uint16_t base_delay_millis, uint16_t jitter_millis, ++ BotFingerprint* fingerprint, uint32_t* due_at_millis); + bool suppress(BotCoordinatorPending pending[], size_t pending_count, BotFingerprint response_fingerprint); + bool cancel(BotCoordinatorPending pending[], size_t pending_count, BotFingerprint request_fingerprint); + BotCoordinatorReady poll(BotCoordinatorPending pending[], size_t pending_count, uint32_t now_millis); diff --git a/tests/firmware_bot/run_tests.py b/tests/firmware_bot/run_tests.py index 17385c2..6997508 100644 --- a/tests/firmware_bot/run_tests.py +++ b/tests/firmware_bot/run_tests.py @@ -21,6 +21,7 @@ def main(): str(SRC_DIR), str(ROOT / "tests" / "firmware_bot" / "test_firmware_bot.cpp"), str(SRC_DIR / "FirmwareBot.cpp"), + str(SRC_DIR / "BotPrefs.cpp"), str(SRC_DIR / "BotPolicy.cpp"), str(SRC_DIR / "BotCommands.cpp"), str(SRC_DIR / "EmergencyForwarder.cpp"), diff --git a/tests/firmware_bot/test_firmware_bot.cpp b/tests/firmware_bot/test_firmware_bot.cpp index 3502629..fedf809 100644 --- a/tests/firmware_bot/test_firmware_bot.cpp +++ b/tests/firmware_bot/test_firmware_bot.cpp @@ -4,6 +4,7 @@ #include "BotCommands.h" #include "BotPolicy.h" +#include "BotPrefs.h" #include "EmergencyForwarder.h" #include "FirmwareBot.h" #include "KnownBotRegistry.h" @@ -34,6 +35,154 @@ static void test_channel_policy() { assert(BotPolicy::decide(BOT_CHANNEL_OTHER) == BOT_POLICY_IGNORE); } +static void test_bot_prefs_defaults() { + BotPrefs prefs; + BotPrefsCodec::defaults(prefs); + assert(prefs.enabled); + assert(prefs.normal_delay_ms == BOT_RESPONSE_DELAY_BASE_MILLIS); + assert(prefs.normal_jitter_ms == BOT_RESPONSE_DELAY_JITTER_MILLIS); + assert(prefs.local_advert_interval_ms == BOT_PREFS_DEFAULT_LOCAL_ADVERT_MILLIS); + assert(prefs.flood_advert_interval_ms == BOT_PREFS_DEFAULT_FLOOD_ADVERT_MILLIS); + assert(strcmp(prefs.bot_channel, "#bot") == 0); + assert(strcmp(prefs.testing_channel, "#testing") == 0); + assert(strcmp(prefs.emergency_channel, "#emergency") == 0); + assert(strcmp(prefs.public_channel, "Public") == 0); + assert(BotPrefsCodec::serializedSize() == BOT_PREFS_SERIALIZED_SIZE); + assert(BotPrefsCodec::commandEnabled(prefs, BOT_COMMAND_PING)); + assert(BotPrefsCodec::commandEnabled(prefs, BOT_COMMAND_UNKNOWN)); +} + +static void test_bot_prefs_serialization_round_trip() { + BotPrefs prefs; + BotPrefsCodec::defaults(prefs); + prefs.enabled = false; + prefs.normal_delay_ms = 2345; + prefs.normal_jitter_ms = 6789; + prefs.local_advert_interval_ms = 123456; + prefs.flood_advert_interval_ms = 654321; + strncpy(prefs.bot_channel, "#ops", sizeof(prefs.bot_channel) - 1); + uint8_t key[BOT_SENDER_KEY_PREFIX_LEN] = { 0xA0, 0xB1, 0xC2, 0xD3, 0xE4, 0xF5 }; + assert(BotPrefsCodec::addKnownBot(prefs, key, BOT_KNOWN_BOT_FLAG_SUPPRESS_NORMAL, "alpha")); + BotPrefsCodec::setCommandEnabled(prefs, BOT_COMMAND_STATUS, false); + + uint8_t blob[BOT_PREFS_SERIALIZED_SIZE]; + assert(BotPrefsCodec::serialize(prefs, blob, sizeof(blob))); + BotPrefs loaded; + assert(BotPrefsCodec::deserialize(blob, sizeof(blob), loaded)); + assert(!loaded.enabled); + assert(loaded.normal_delay_ms == 2345); + assert(loaded.normal_jitter_ms == 6789); + assert(loaded.local_advert_interval_ms == 123456); + assert(loaded.flood_advert_interval_ms == 654321); + assert(strcmp(loaded.bot_channel, "#ops") == 0); + assert(!BotPrefsCodec::commandEnabled(loaded, BOT_COMMAND_STATUS)); + assert(BotPrefsCodec::findKnownBot(loaded, key) != NULL); + assert(strcmp(BotPrefsCodec::findKnownBot(loaded, key)->label, "alpha") == 0); +} + +static void test_bot_prefs_rejects_corrupt_and_wrong_version() { + BotPrefs prefs; + BotPrefsCodec::defaults(prefs); + uint8_t blob[BOT_PREFS_SERIALIZED_SIZE]; + assert(BotPrefsCodec::serialize(prefs, blob, sizeof(blob))); + + BotPrefs loaded; + blob[20] ^= 0x55; + assert(!BotPrefsCodec::deserialize(blob, sizeof(blob), loaded)); + assert(loaded.enabled); + assert(strcmp(loaded.bot_channel, "#bot") == 0); + + assert(BotPrefsCodec::serialize(prefs, blob, sizeof(blob))); + blob[4] = 0x7F; + assert(!BotPrefsCodec::deserialize(blob, sizeof(blob), loaded)); + assert(strcmp(loaded.public_channel, "Public") == 0); + assert(!BotPrefsCodec::deserialize(blob, sizeof(blob) - 1, loaded)); +} + +static void test_bot_prefs_validation_and_command_mask() { + BotPrefs prefs; + BotPrefsCodec::defaults(prefs); + prefs.normal_delay_ms = BOT_PREFS_MAX_DELAY_MILLIS + 1; + prefs.normal_jitter_ms = BOT_PREFS_MAX_DELAY_MILLIS + 1; + prefs.local_advert_interval_ms = BOT_PREFS_MAX_ADVERT_MILLIS + 1; + prefs.flood_advert_interval_ms = BOT_PREFS_MAX_ADVERT_MILLIS + 1; + prefs.max_response_parts = 99; + prefs.bot_channel[0] = 0; + prefs.command_mask = 0xFFFFFFFFUL; + BotPrefsCodec::validate(prefs); + assert(prefs.normal_delay_ms == BOT_PREFS_MAX_DELAY_MILLIS); + assert(prefs.normal_jitter_ms == BOT_PREFS_MAX_DELAY_MILLIS); + assert(prefs.local_advert_interval_ms == BOT_PREFS_MAX_ADVERT_MILLIS); + assert(prefs.flood_advert_interval_ms == BOT_PREFS_MAX_ADVERT_MILLIS); + assert(prefs.max_response_parts == BOT_EMERGENCY_MAX_PARTS); + assert(strcmp(prefs.bot_channel, "#bot") == 0); + assert((prefs.command_mask & ~BOT_COMMAND_MASK_ALL) == 0); + assert(BotPrefsCodec::channelNameValid("#ops", false)); + assert(!BotPrefsCodec::channelNameValid("ops", false)); + assert(BotPrefsCodec::channelNameValid("Public", true)); + assert(!BotPrefsCodec::channelNameValid("#Public", true)); + assert(!BotPrefsCodec::channelNameValid("#abcdefghijklmnopqrstuvw", false)); + + BotPrefs duplicate_channels; + BotPrefsCodec::defaults(duplicate_channels); + strncpy(duplicate_channels.testing_channel, "#bot", sizeof(duplicate_channels.testing_channel) - 1); + assert(!BotPrefsCodec::channelConfigValid(duplicate_channels)); + BotPrefsCodec::validate(duplicate_channels); + assert(strcmp(duplicate_channels.testing_channel, "#testing") == 0); + + BotPrefsCodec::setCommandEnabled(prefs, BOT_COMMAND_PING, false); + assert(!BotPrefsCodec::commandEnabled(prefs, BOT_COMMAND_PING)); + BotPrefsCodec::setCommandEnabled(prefs, BOT_COMMAND_PING, true); + assert(BotPrefsCodec::commandEnabled(prefs, BOT_COMMAND_PING)); + BotCommandId id = BOT_COMMAND_NONE; + assert(BotPrefsCodec::commandIdForName("ROLL", &id)); + assert(id == BOT_COMMAND_DICE); + assert(!BotPrefsCodec::commandIdForName("nope", &id)); +} + +static void test_bot_prefs_known_bot_helpers() { + BotPrefs prefs; + BotPrefsCodec::defaults(prefs); + uint8_t key1[BOT_SENDER_KEY_PREFIX_LEN]; + uint8_t key2[BOT_SENDER_KEY_PREFIX_LEN]; + char hex[BOT_SENDER_KEY_PREFIX_LEN * 2 + 1]; + assert(BotPrefsCodec::parseKeyPrefixHex("010203040506", key1)); + assert(!BotPrefsCodec::parseKeyPrefixHex("01020304050", key2)); + assert(!BotPrefsCodec::parseKeyPrefixHex("01020304050x", key2)); + BotPrefsCodec::formatKeyPrefixHex(key1, hex, sizeof(hex)); + assert(strcmp(hex, "010203040506") == 0); + assert(BotPrefsCodec::addKnownBot(prefs, key1, BOT_KNOWN_BOT_FLAG_SUPPRESS_NORMAL, "alpha")); + assert(BotPrefsCodec::findKnownBot(prefs, key1) != NULL); + assert(BotPrefsCodec::addKnownBot(prefs, key1, 0, "beta")); + assert(BotPrefsCodec::findKnownBot(prefs, key1)->flags == 0); + assert(strcmp(BotPrefsCodec::findKnownBot(prefs, key1)->label, "beta") == 0); + assert(BotPrefsCodec::removeKnownBot(prefs, key1)); + assert(!BotPrefsCodec::removeKnownBot(prefs, key1)); + + for (size_t i = 0; i < BOT_KNOWN_BOT_SLOTS; i++) { + for (size_t j = 0; j < BOT_SENDER_KEY_PREFIX_LEN; j++) key2[j] = (uint8_t)(i * 10 + j); + assert(BotPrefsCodec::addKnownBot(prefs, key2, BOT_KNOWN_BOT_FLAG_SUPPRESS_NORMAL, "bot")); + } + uint8_t overflow[BOT_SENDER_KEY_PREFIX_LEN] = { 99, 98, 97, 96, 95, 94 }; + assert(!BotPrefsCodec::addKnownBot(prefs, overflow, BOT_KNOWN_BOT_FLAG_SUPPRESS_NORMAL, "full")); +} + +static void test_prefs_aware_channel_policy() { + BotPrefs prefs; + BotPrefsCodec::defaults(prefs); + strncpy(prefs.bot_channel, "#ops", sizeof(prefs.bot_channel) - 1); + strncpy(prefs.testing_channel, "#lab", sizeof(prefs.testing_channel) - 1); + strncpy(prefs.emergency_channel, "#sos", sizeof(prefs.emergency_channel) - 1); + strncpy(prefs.public_channel, "Town", sizeof(prefs.public_channel) - 1); + assert(BotPolicy::classifyChannel("#ops", 4, false, prefs) == BOT_CHANNEL_BOT); + assert(BotPolicy::classifyChannel("ops", 3, false, prefs) == BOT_CHANNEL_BOT); + assert(BotPolicy::classifyChannel("#lab", 4, false, prefs) == BOT_CHANNEL_TESTING); + assert(BotPolicy::classifyChannel("#sos", 4, false, prefs) == BOT_CHANNEL_EMERGENCY); + assert(BotPolicy::classifyChannel("#SOS", 4, false, prefs) == BOT_CHANNEL_OTHER); + assert(BotPolicy::classifyChannel("Town", 4, false, prefs) == BOT_CHANNEL_PUBLIC); + assert(BotPolicy::classifyChannel("#bot", 4, false, prefs) == BOT_CHANNEL_OTHER); +} + static void test_normalize_text() { char out[32]; size_t written = 0; @@ -143,6 +292,10 @@ static BotCommandContext make_context() { BotCommandContext context; memset(&context, 0, sizeof(context)); strncpy(context.node_name, "cm-bot", sizeof(context.node_name) - 1); + strncpy(context.bot_channel, "#bot", sizeof(context.bot_channel) - 1); + strncpy(context.testing_channel, "#testing", sizeof(context.testing_channel) - 1); + strncpy(context.emergency_channel, "#emergency", sizeof(context.emergency_channel) - 1); + strncpy(context.public_channel, "Public", sizeof(context.public_channel) - 1); context.uptime_seconds = 1234; context.battery_millivolts = 4180; context.storage_used_kb = 12; @@ -190,7 +343,7 @@ static void test_command_outputs() { 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); + assert(strcmp(out, "Channels: #bot #testing emergency=#emergency public=Public (4 configured)") == 0); result = run_command("!status", out, sizeof(out)); assert(result.code == BOT_COMMAND_RESULT_OK); @@ -662,6 +815,12 @@ static void test_response_coordinator_rejects_non_normal() { int main() { test_channel_policy(); + test_bot_prefs_defaults(); + test_bot_prefs_serialization_round_trip(); + test_bot_prefs_rejects_corrupt_and_wrong_version(); + test_bot_prefs_validation_and_command_mask(); + test_bot_prefs_known_bot_helpers(); + test_prefs_aware_channel_policy(); test_normalize_text(); test_normalize_truncation(); test_parse_command();