From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 From: cj-vana Date: Fri, 15 May 2026 11:44:21 -0600 Subject: [PATCH 1/8] Add companion radio firmware bot command parity --- .../companion_radio/BotCommandRegistry.cpp | 121 ++ examples/companion_radio/BotCommandRegistry.h | 17 + examples/companion_radio/BotCommands.cpp | 357 ++++++ examples/companion_radio/BotCommands.h | 10 + examples/companion_radio/BotPolicy.cpp | 81 ++ examples/companion_radio/BotPolicy.h | 14 + examples/companion_radio/BotPrefs.cpp | 364 ++++++ examples/companion_radio/BotPrefs.h | 31 + examples/companion_radio/BotTypes.h | 331 +++++ examples/companion_radio/DataStore.cpp | 36 + examples/companion_radio/DataStore.h | 11 + .../companion_radio/EmergencyForwarder.cpp | 118 ++ examples/companion_radio/EmergencyForwarder.h | 10 + examples/companion_radio/FirmwareBot.cpp | 272 ++++ examples/companion_radio/FirmwareBot.h | 22 + examples/companion_radio/KnownBotRegistry.cpp | 87 ++ examples/companion_radio/KnownBotRegistry.h | 16 + examples/companion_radio/MyMesh.cpp | 1142 ++++++++++++++++- examples/companion_radio/MyMesh.h | 93 ++ .../companion_radio/ResponseCoordinator.cpp | 210 +++ .../companion_radio/ResponseCoordinator.h | 33 + platformio.ini | 8 + variants/heltec_v3/platformio.ini | 2 + variants/rak4631/platformio.ini | 2 + 24 files changed, 3385 insertions(+), 3 deletions(-) create mode 100644 examples/companion_radio/BotCommandRegistry.cpp create mode 100644 examples/companion_radio/BotCommandRegistry.h create mode 100644 examples/companion_radio/BotCommands.cpp create mode 100644 examples/companion_radio/BotCommands.h create mode 100644 examples/companion_radio/BotPolicy.cpp create mode 100644 examples/companion_radio/BotPolicy.h create mode 100644 examples/companion_radio/BotPrefs.cpp create mode 100644 examples/companion_radio/BotPrefs.h create mode 100644 examples/companion_radio/BotTypes.h create mode 100644 examples/companion_radio/EmergencyForwarder.cpp create mode 100644 examples/companion_radio/EmergencyForwarder.h create mode 100644 examples/companion_radio/FirmwareBot.cpp create mode 100644 examples/companion_radio/FirmwareBot.h create mode 100644 examples/companion_radio/KnownBotRegistry.cpp create mode 100644 examples/companion_radio/KnownBotRegistry.h create mode 100644 examples/companion_radio/ResponseCoordinator.cpp create mode 100644 examples/companion_radio/ResponseCoordinator.h diff --git a/examples/companion_radio/BotCommandRegistry.cpp b/examples/companion_radio/BotCommandRegistry.cpp new file mode 100644 index 00000000..4fcf07a2 --- /dev/null +++ b/examples/companion_radio/BotCommandRegistry.cpp @@ -0,0 +1,121 @@ +#include "BotCommandRegistry.h" + +#include +#include + +namespace { + +const char* const kCmdAliases[] = { "commands" }; +const char* const kTestAliases[] = { "t" }; +const char* const kHelloAliases[] = { "hi" }; +const char* const kVersionAliases[] = { "ver" }; +const char* const kMagic8Aliases[] = { "8ball", "eightball" }; +const char* const kPathAliases[] = { "p", "decode", "route" }; +const char* const kUnsupportedAliases[] = { + "ack", "bbs", "blacklist", "delete", "download", "email", "heard", "history", "ignore", "info", + "last", "lheard", "location", "map", "mqtt", "nodes", "reboot", "set", "sms", "telemetry", + "upload", "weather", "whereami" +}; + +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; +} + +bool namesEqual(const char* name, size_t len, const char* expected) { + size_t expected_len = boundedStrLen(expected, BOT_MAX_COMMAND_NAME_LEN + 1); + if (len != expected_len) return false; + for (size_t i = 0; i < len; i++) { + if (tolower((unsigned char)name[i]) != tolower((unsigned char)expected[i])) return false; + } + return true; +} + +const BotCommandMetadata kCommands[] = { + { BOT_COMMAND_HELP, "help", NULL, 0, BOT_COMMAND_MASK_HELP, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_NORMAL, "Show bot help", "help [command]", "Show available commands or details for one command." }, + { BOT_COMMAND_CMD, "cmd", kCmdAliases, 1, BOT_COMMAND_MASK_CMD, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_NORMAL, "List commands", "cmd", "List compact command names supported by this firmware bot." }, + { BOT_COMMAND_PING, "ping", NULL, 0, BOT_COMMAND_MASK_PING, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_NORMAL, "Check bot response", "ping", "Reply with Pong when the bot is alive." }, + { BOT_COMMAND_TEST, "test", kTestAliases, 1, BOT_COMMAND_MASK_TEST, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_NORMAL, "Run a bot test", "test", "Return a short firmware bot self-test response." }, + { BOT_COMMAND_HELLO, "hello", kHelloAliases, 1, BOT_COMMAND_MASK_HELLO, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_NORMAL, "Greet from the node", "hello", "Reply with the local bot node name." }, + { BOT_COMMAND_ABOUT, "about", NULL, 0, BOT_COMMAND_MASK_ABOUT, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_NORMAL, "Describe this bot", "about", "Describe the local firmware bot." }, + { BOT_COMMAND_ROLL, "roll", NULL, 0, BOT_COMMAND_MASK_ROLL, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_NORMAL, "Roll a numeric range", "roll [max|low high]", "Roll within a numeric range." }, + { BOT_COMMAND_DICE, "dice", NULL, 0, BOT_COMMAND_MASK_DICE, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_NORMAL, "Roll dice notation", "dice [dN|NdN]", "Roll dice notation with bounded dice and sides." }, + { BOT_COMMAND_STATUS, "status", NULL, 0, BOT_COMMAND_MASK_STATUS, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_DIAGNOSTIC, "Show node status", "status", "Show local uptime, battery, storage, and bot send counters." }, + { BOT_COMMAND_CHANNELS, "channels", NULL, 0, BOT_COMMAND_MASK_CHANNELS, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_DIAGNOSTIC, "Show configured channels", "channels", "Show local bot, testing, emergency, and public channel names." }, + { BOT_COMMAND_VERSION, "version", kVersionAliases, 1, BOT_COMMAND_MASK_VERSION, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_NORMAL, "Show firmware version", "version", "Show local firmware version and build date." }, + { BOT_COMMAND_STATS, "stats", NULL, 0, BOT_COMMAND_MASK_STATS, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_DIAGNOSTIC, "Show bot counters", "stats", "Show local bot and RF counters." }, + { BOT_COMMAND_MAGIC8, "magic8", kMagic8Aliases, 2, BOT_COMMAND_MASK_MAGIC8, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_NORMAL, "Ask the magic 8-ball", "magic8 ", "Return a short pseudo-random magic 8-ball answer." }, + { BOT_COMMAND_PATH, "path", kPathAliases, 3, BOT_COMMAND_MASK_PATH, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_TRACE, "Show last path", "path", "Show the latest local packet path hashes, hash width, and SNR if available." }, + { BOT_COMMAND_TRACE, "trace", NULL, 0, BOT_COMMAND_MASK_TRACE, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_TRACE, "Send active trace", "trace [hex-path]", "Send a bounded active trace request using local MeshCore state." }, + { BOT_COMMAND_TRACER, "tracer", NULL, 0, BOT_COMMAND_MASK_TRACER, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_TRACE, "Show route trace", "tracer", "Show the current packet route hashes without sending an active trace." }, + { BOT_COMMAND_PREFIX, "prefix", NULL, 0, BOT_COMMAND_MASK_PREFIX, BOT_COMMAND_VISIBILITY_DISCOVERABLE, + BOT_COMMAND_CONTEXT_LOCAL_CONTACT, "Look up local prefix", "prefix ", "Look up a local contact by public-key prefix using local firmware contacts only." }, + { BOT_COMMAND_UNSUPPORTED, "unsupported", kUnsupportedAliases, 23, 0, BOT_COMMAND_VISIBILITY_HIDDEN, + BOT_COMMAND_CONTEXT_UNSUPPORTED, "Unavailable in firmware", "", "This upstream host command is unavailable in firmware." }, + { BOT_COMMAND_UNKNOWN, "unknown", NULL, 0, 0, BOT_COMMAND_VISIBILITY_INTERNAL, + BOT_COMMAND_CONTEXT_INTERNAL, "Unknown command", "unknown", "Internal unknown-command handler." } +}; + +} + +namespace BotCommandRegistry { + +size_t commandCount() { + return sizeof(kCommands) / sizeof(kCommands[0]); +} + +const BotCommandMetadata* commandAt(size_t index) { + return index < commandCount() ? &kCommands[index] : NULL; +} + +const BotCommandMetadata* findById(BotCommandId id) { + for (size_t i = 0; i < commandCount(); i++) { + if (kCommands[i].id == id) return &kCommands[i]; + } + return NULL; +} + +const BotCommandMetadata* findByName(const char* name, size_t len) { + if (!name || len == 0 || len > BOT_MAX_COMMAND_NAME_LEN) return NULL; + for (size_t i = 0; i < commandCount(); i++) { + if (namesEqual(name, len, kCommands[i].name)) return &kCommands[i]; + for (uint8_t j = 0; j < kCommands[i].alias_count; j++) { + if (namesEqual(name, len, kCommands[i].aliases[j])) return &kCommands[i]; + } + } + return NULL; +} + +const char* commandName(BotCommandId id) { + const BotCommandMetadata* command = findById(id); + return command ? command->name : ""; +} + +uint32_t commandMask(BotCommandId id) { + const BotCommandMetadata* command = findById(id); + return command ? command->mask : 0; +} + +bool isDiscoverable(BotCommandId id) { + const BotCommandMetadata* command = findById(id); + return command && command->visibility == BOT_COMMAND_VISIBILITY_DISCOVERABLE; +} + +} diff --git a/examples/companion_radio/BotCommandRegistry.h b/examples/companion_radio/BotCommandRegistry.h new file mode 100644 index 00000000..e5aae4dd --- /dev/null +++ b/examples/companion_radio/BotCommandRegistry.h @@ -0,0 +1,17 @@ +#pragma once + +#include "BotTypes.h" + +#include + +namespace BotCommandRegistry { + +size_t commandCount(); +const BotCommandMetadata* commandAt(size_t index); +const BotCommandMetadata* findById(BotCommandId id); +const BotCommandMetadata* findByName(const char* name, size_t len); +const char* commandName(BotCommandId id); +uint32_t commandMask(BotCommandId id); +bool isDiscoverable(BotCommandId id); + +} diff --git a/examples/companion_radio/BotCommands.cpp b/examples/companion_radio/BotCommands.cpp new file mode 100644 index 00000000..a7d3637f --- /dev/null +++ b/examples/companion_radio/BotCommands.cpp @@ -0,0 +1,357 @@ +#include "BotCommands.h" + +#include "BotCommandRegistry.h" + +#include +#include +#include +#include + +namespace { + +BotCommandResult makeResult(BotCommandResultCode code, size_t text_len) { + BotCommandResult result = { code, text_len }; + return result; +} + +size_t boundedStrLen(const char* value, size_t max_len) { + size_t len = 0; + while (value && len < max_len && value[len] != 0) len++; + return len; +} + +BotCommandResult writeText(char* output, size_t output_len, const char* text) { + if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); + + size_t text_len = boundedStrLen(text, BOT_MAX_RESPONSE_LEN + 1); + size_t copy_len = text_len; + if (copy_len + 1 > output_len) copy_len = output_len - 1; + if (copy_len > 0) memcpy(output, text, copy_len); + output[copy_len] = 0; + + return makeResult(copy_len < text_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, copy_len); +} + +BotCommandResult writeFormatted(char* output, size_t output_len, const char* format, ...) { + if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); + + va_list args; + va_start(args, format); + int n = vsnprintf(output, output_len, format, args); + va_end(args); + + if (n < 0) { + output[0] = 0; + return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); + } + + size_t written = (size_t)n; + if (written >= output_len) written = output_len - 1; + return makeResult((size_t)n >= output_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, written); +} + +void appendText(char* output, size_t output_len, size_t* pos, const char* text) { + if (!output || output_len == 0 || !pos || !text) return; + for (size_t i = 0; text[i] != 0; i++) { + if (*pos + 1 < output_len) output[*pos] = text[i]; + (*pos)++; + } + output[*pos < output_len ? *pos : output_len - 1] = 0; +} + +BotCommandResult resultForAppend(char* output, size_t output_len, size_t pos) { + if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); + size_t actual = boundedStrLen(output, output_len); + return makeResult(pos >= output_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, actual); +} + +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 >= 2 && sides <= 1000; +} + +void skipSpaces(const char* text, size_t len, size_t* pos) { + while (*pos < len && text[*pos] == ' ') (*pos)++; +} + +bool parseRoll(const BotCommand& command, uint16_t* low, uint16_t* high) { + *low = 1; + *high = 100; + if (command.args_len == 0) return true; + + const char* text = command.args; + size_t len = command.args_len; + size_t pos = 0; + uint16_t first = 0; + if (!parseUInt(text, len, &pos, &first) || first == 0) return false; + + skipSpaces(text, len, &pos); + if (pos == len) { + *high = first; + return true; + } + + uint16_t second = 0; + if (!parseUInt(text, len, &pos, &second) || second == 0) return false; + skipSpaces(text, len, &pos); + if (pos != len || first > second) return false; + + *low = first; + *high = second; + return true; +} + +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 { + if (!parseUInt(text, len, &pos, count)) return false; + if (pos >= len || (text[pos] != 'd' && text[pos] != 'D')) return false; + pos++; + if (!parseUInt(text, len, &pos, sides)) return false; + } + + 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; +} + +void appendPathHex(char* output, size_t output_len, size_t* pos, const uint8_t* path, size_t path_len) { + static const char hex[] = "0123456789abcdef"; + for (size_t i = 0; i < path_len; i++) { + if (*pos + 2 < output_len) { + output[*pos] = hex[path[i] >> 4]; + output[*pos + 1] = hex[path[i] & 0x0F]; + } + *pos += 2; + } + if (output_len > 0) output[*pos < output_len ? *pos : output_len - 1] = 0; +} + +void formatQuarters(int8_t quarters, char* output, size_t output_len) { + if (!output || output_len == 0) return; + int value = quarters; + const char* sign = value < 0 ? "-" : ""; + if (value < 0) value = -value; + snprintf(output, output_len, "%s%d.%02d", sign, value / 4, (value % 4) * 25); +} + +BotCommandResult executeCmd(char* output, size_t output_len) { + if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); + output[0] = 0; + size_t pos = 0; + bool first = true; + for (size_t i = 0; i < BotCommandRegistry::commandCount(); i++) { + const BotCommandMetadata* command = BotCommandRegistry::commandAt(i); + if (!command || command->visibility != BOT_COMMAND_VISIBILITY_DISCOVERABLE) continue; + if (!first) appendText(output, output_len, &pos, " "); + appendText(output, output_len, &pos, command->name); + first = false; + } + return resultForAppend(output, output_len, pos); +} + +BotCommandResult executeHelp(const BotCommand& command, char* output, size_t output_len) { + if (command.args_len == 0) { + if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); + output[0] = 0; + size_t pos = 0; + appendText(output, output_len, &pos, "Commands: "); + bool first = true; + for (size_t i = 0; i < BotCommandRegistry::commandCount(); i++) { + const BotCommandMetadata* metadata = BotCommandRegistry::commandAt(i); + if (!metadata || metadata->visibility != BOT_COMMAND_VISIBILITY_DISCOVERABLE) continue; + if (!first) appendText(output, output_len, &pos, " "); + appendText(output, output_len, &pos, metadata->name); + first = false; + } + appendText(output, output_len, &pos, "; help "); + return resultForAppend(output, output_len, pos); + } + + const BotCommandMetadata* metadata = BotCommandRegistry::findByName(command.args, command.args_len); + if (!metadata) return writeFormatted(output, output_len, "No help for %s", command.args); + if (metadata->id == BOT_COMMAND_UNSUPPORTED) { + return writeFormatted(output, output_len, "%s is unavailable in firmware", command.args); + } + if (metadata->visibility != BOT_COMMAND_VISIBILITY_DISCOVERABLE) { + return writeFormatted(output, output_len, "%s is not available", command.args); + } + return writeFormatted(output, output_len, "%s: %s. Usage: %s", metadata->name, metadata->details, metadata->usage); +} + +BotCommandResult executeMagic8(const BotCommandContext& context, char* output, size_t output_len) { + static const char* responses[] = { + "It is certain", "Looks good", "Ask again later", "Cannot predict now", "Doubtful", "Very likely", + "Signs point yes", "No", "Reply hazy", "Absolutely" + }; + uint32_t seed = context.random_seed ? context.random_seed : 1; + seed = seed * 1664525UL + 1013904223UL; + return writeFormatted(output, output_len, "Magic 8-ball: %s", responses[(seed >> 16) % (sizeof(responses) / sizeof(responses[0]))]); +} + +BotCommandResult executePathLike(const BotCommandContext& context, char* output, size_t output_len, const char* label) { + if (!context.path || context.path_len == 0 || context.path_hash_count == 0 || context.path_hash_size == 0) { + return writeFormatted(output, output_len, "%s unavailable", label); + } + if (!output || output_len == 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); + + size_t byte_len = (size_t)context.path_hash_size * context.path_hash_count; + char snr[8]; + formatQuarters(context.path_snr_quarters, snr, sizeof(snr)); + int written = snprintf(output, output_len, "%s %uh x %uB snr %s: ", label, (unsigned)context.path_hash_count, + (unsigned)context.path_hash_size, snr); + if (written < 0) return makeResult(BOT_COMMAND_RESULT_NO_SPACE, 0); + size_t pos = (size_t)written; + appendPathHex(output, output_len, &pos, context.path, byte_len); + size_t actual = boundedStrLen(output, output_len); + return makeResult(pos >= output_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, actual); +} + +BotCommandResult executePath(const BotCommandContext& context, char* output, size_t output_len) { + return executePathLike(context, output, output_len, "Path"); +} + +BotCommandResult executeTracer(const BotCommand& command, const BotCommandContext& context, char* output, size_t output_len) { + if (command.args_len != 0) return writeText(output, output_len, "Usage: tracer"); + return executePathLike(context, output, output_len, "Tracer"); +} + +BotCommandResult executeRoll(const BotCommand& command, const BotCommandContext& context, char* output, size_t output_len) { + uint16_t low = 1; + uint16_t high = 100; + if (!parseRoll(command, &low, &high)) return writeText(output, output_len, "Usage: roll [max|low high], range 1-1000"); + + uint32_t state = context.random_seed ^ ((uint32_t)low << 16) ^ high; + if (state == 0) state = 1; + uint16_t span = (uint16_t)(high - low + 1); + uint16_t value = (uint16_t)(low + rollOnce(&state, span) - 1); + return writeFormatted(output, output_len, "Rolled %u-%u: %u", (unsigned)low, (unsigned)high, (unsigned)value); +} + +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: dice [dN|NdN], max 10 dice, sides 2-1000"); + } + + 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, "Dice d%u: ", (unsigned)sides) + : snprintf(output, output_len, "Dice %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 executeHelp(command, output, output_len); + case BOT_COMMAND_CMD: + return executeCmd(output, output_len); + 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_ROLL: + return executeRoll(command, context, output, output_len); + 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, "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_VERSION: + return writeFormatted(output, output_len, "Firmware %s built %s", context.firmware_version[0] ? context.firmware_version : "unknown", + context.firmware_build_date[0] ? context.firmware_build_date : "unknown"); + case BOT_COMMAND_STATS: + return writeFormatted(output, output_len, "Bot seen %lu ok %lu sent %lu fail %lu sup %lu pend %lu rf rx/tx %lu/%lu err %lu q %u", + (unsigned long)context.observed_messages, (unsigned long)context.eligible_messages, + (unsigned long)context.sent_messages, (unsigned long)context.send_failures, + (unsigned long)context.suppressed_responses, (unsigned long)context.pending_responses, + (unsigned long)context.packets_recv, (unsigned long)context.packets_sent, + (unsigned long)context.packets_recv_errors, (unsigned)context.queue_depth); + case BOT_COMMAND_MAGIC8: + return executeMagic8(context, output, output_len); + case BOT_COMMAND_PATH: + return executePath(context, output, output_len); + case BOT_COMMAND_TRACE: + return command.args_len == 0 ? writeText(output, output_len, "Trace route unavailable") + : writeText(output, output_len, "Usage: trace [hex-path]"); + case BOT_COMMAND_TRACER: + return executeTracer(command, context, output, output_len); + case BOT_COMMAND_PREFIX: + return writeText(output, output_len, "Prefix lookup unavailable"); + case BOT_COMMAND_UNSUPPORTED: + return writeFormatted(output, output_len, "%s is unavailable in firmware", command.name); + 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/BotPolicy.cpp b/examples/companion_radio/BotPolicy.cpp new file mode 100644 index 00000000..33de45f8 --- /dev/null +++ b/examples/companion_radio/BotPolicy.cpp @@ -0,0 +1,81 @@ +#include "BotPolicy.h" + +#include +#include + +namespace { + +bool equalsIgnoreCase(const char* value, size_t len, const char* expected) { + if (!value || !expected) return false; + + if (len > 0 && value[0] == '#') { + value++; + len--; + } + + size_t expected_len = strlen(expected); + if (expected_len > 0 && expected[0] == '#') { + expected++; + expected_len--; + } + if (len != expected_len) return false; + + for (size_t i = 0; i < len; i++) { + if (tolower((unsigned char)value[i]) != tolower((unsigned char)expected[i])) return false; + } + return true; +} + +bool equalsExact(const char* value, size_t len, const char* expected) { + if (!value || !expected) return false; + size_t expected_len = strlen(expected); + if (len != expected_len) return false; + for (size_t i = 0; i < len; i++) { + if (value[i] != expected[i]) return false; + } + return true; +} + +} + +namespace BotPolicy { + +BotChannelKind classifyChannel(const char* name, size_t len, bool direct_message) { + if (direct_message) return BOT_CHANNEL_DM; + if (equalsExact(name, len, "Public")) return BOT_CHANNEL_PUBLIC; + if (equalsIgnoreCase(name, len, "#bot")) return BOT_CHANNEL_BOT; + if (equalsIgnoreCase(name, len, "#testing")) return BOT_CHANNEL_TESTING; + if (equalsExact(name, len, "#emergency")) return BOT_CHANNEL_EMERGENCY; + 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; + } + if (kind == BOT_CHANNEL_EMERGENCY) return BOT_POLICY_EMERGENCY_FORWARD; + return BOT_POLICY_IGNORE; +} + +bool isNormalAllowed(BotChannelKind kind) { + return decide(kind) == BOT_POLICY_ALLOW_NORMAL; +} + +bool isEmergency(BotChannelKind kind) { + return decide(kind) == BOT_POLICY_EMERGENCY_FORWARD; +} + +bool isPrefixlessCommandAllowed(BotChannelKind kind) { + return kind == BOT_CHANNEL_BOT || kind == BOT_CHANNEL_TESTING; +} + +} diff --git a/examples/companion_radio/BotPolicy.h b/examples/companion_radio/BotPolicy.h new file mode 100644 index 00000000..69910692 --- /dev/null +++ b/examples/companion_radio/BotPolicy.h @@ -0,0 +1,14 @@ +#pragma once + +#include "BotTypes.h" + +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); +bool isPrefixlessCommandAllowed(BotChannelKind kind); + +} diff --git a/examples/companion_radio/BotPrefs.cpp b/examples/companion_radio/BotPrefs.cpp new file mode 100644 index 00000000..799b6458 --- /dev/null +++ b/examples/companion_radio/BotPrefs.cpp @@ -0,0 +1,364 @@ +#include "BotPrefs.h" + +#include "BotCommandRegistry.h" + +#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) { + return BotCommandRegistry::commandMask(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) { + return BotCommandRegistry::commandName(command_id); +} + +bool commandIdForName(const char* name, BotCommandId* command_id) { + if (!name || !command_id) return false; + size_t len = boundedStrLen(name, BOT_MAX_COMMAND_NAME_LEN + 1); + const BotCommandMetadata* command = BotCommandRegistry::findByName(name, len); + if (!command || command->mask == 0) return false; + *command_id = command->id; + return true; +} + +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 new file mode 100644 index 00000000..5c7430a9 --- /dev/null +++ b/examples/companion_radio/BotTypes.h @@ -0,0 +1,331 @@ +#pragma once + +#include +#include + +#define BOT_MAX_TEXT_LEN 160 +#define BOT_MAX_RESPONSE_LEN 144 +#define BOT_MAX_COMMAND_NAME_LEN 15 +#define BOT_MAX_COMMAND_ARGS_LEN 79 +#define BOT_MAX_CHANNEL_NAME_LEN 23 +#define BOT_MAX_SENDER_NAME_LEN 31 +#define BOT_MAX_FIRMWARE_VERSION_LEN 19 +#define BOT_MAX_BUILD_DATE_LEN 15 +#define BOT_MAX_PATH_BYTES 64 +#define BOT_GROUP_RESPONSE_PREFIX_RESERVE (BOT_MAX_SENDER_NAME_LEN + 2) +#define BOT_MAX_GROUP_RESPONSE_LEN (BOT_MAX_TEXT_LEN - BOT_GROUP_RESPONSE_PREFIX_RESERVE) +#define BOT_COMMAND_COOLDOWN_MILLIS 5000UL +#define BOT_TRACE_COOLDOWN_MILLIS 60000UL +#define BOT_TRACE_TIMEOUT_MILLIS 30000UL +#define BOT_PENDING_TRACE_SLOTS 2 +#define BOT_EMERGENCY_PREFIX "EMERGENCY MESSAGE FROM " +#define BOT_EMERGENCY_MAX_PARTS 3 +#define BOT_PENDING_EMERGENCY_SLOTS BOT_EMERGENCY_MAX_PARTS +#define BOT_COORDINATOR_PENDING_SLOTS 8 +#define BOT_COORDINATOR_RECENT_SLOTS 16 +#define BOT_KNOWN_BOT_SLOTS 8 +#define BOT_RESPONSE_DELAY_BASE_MILLIS 1200UL +#define BOT_RESPONSE_DELAY_JITTER_MILLIS 1800UL +#define BOT_RESPONSE_PENDING_TTL_MILLIS 15000UL +#define BOT_RESPONSE_RECENT_TTL_MILLIS 30000UL +#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_PREFS_SERIALIZED_SIZE 294 + +enum BotChannelKind : uint8_t { + BOT_CHANNEL_DM = 0, + BOT_CHANNEL_PUBLIC, + BOT_CHANNEL_BOT, + BOT_CHANNEL_TESTING, + BOT_CHANNEL_EMERGENCY, + BOT_CHANNEL_OTHER +}; + +enum BotPolicyDecision : uint8_t { + BOT_POLICY_IGNORE = 0, + BOT_POLICY_ALLOW_NORMAL, + BOT_POLICY_EMERGENCY_FORWARD +}; + +enum BotCommandId : uint8_t { + BOT_COMMAND_NONE = 0, + BOT_COMMAND_HELP = 1, + BOT_COMMAND_CMD = 2, + BOT_COMMAND_PING = 3, + BOT_COMMAND_TEST = 4, + BOT_COMMAND_HELLO = 5, + BOT_COMMAND_ABOUT = 6, + BOT_COMMAND_ROLL = 7, + BOT_COMMAND_DICE = 8, + BOT_COMMAND_STATUS = 9, + BOT_COMMAND_CHANNELS = 10, + BOT_COMMAND_VERSION = 11, + BOT_COMMAND_STATS = 12, + BOT_COMMAND_MAGIC8 = 13, + BOT_COMMAND_PATH = 14, + BOT_COMMAND_TRACE = 15, + BOT_COMMAND_TRACER = 16, + BOT_COMMAND_PREFIX = 17, + BOT_COMMAND_UNSUPPORTED = 18, + BOT_COMMAND_UNKNOWN = 19 +}; + +enum BotCommandVisibility : uint8_t { + BOT_COMMAND_VISIBILITY_DISCOVERABLE = 0, + BOT_COMMAND_VISIBILITY_HIDDEN, + BOT_COMMAND_VISIBILITY_INTERNAL +}; + +enum BotCommandContextClass : uint8_t { + BOT_COMMAND_CONTEXT_NORMAL = 0, + BOT_COMMAND_CONTEXT_DIAGNOSTIC, + BOT_COMMAND_CONTEXT_TRACE, + BOT_COMMAND_CONTEXT_LOCAL_CONTACT, + BOT_COMMAND_CONTEXT_UNSUPPORTED, + BOT_COMMAND_CONTEXT_INTERNAL +}; + +struct BotCommandMetadata { + BotCommandId id; + const char* name; + const char* const* aliases; + uint8_t alias_count; + uint32_t mask; + BotCommandVisibility visibility; + BotCommandContextClass context_class; + const char* summary; + const char* usage; + const char* details; +}; + +#define BOT_COMMAND_MASK_HELP (1UL << BOT_COMMAND_HELP) +#define BOT_COMMAND_MASK_CMD (1UL << BOT_COMMAND_CMD) +#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_ROLL (1UL << BOT_COMMAND_ROLL) +#define BOT_COMMAND_MASK_DICE (1UL << BOT_COMMAND_DICE) +#define BOT_COMMAND_MASK_STATUS (1UL << BOT_COMMAND_STATUS) +#define BOT_COMMAND_MASK_CHANNELS (1UL << BOT_COMMAND_CHANNELS) +#define BOT_COMMAND_MASK_VERSION (1UL << BOT_COMMAND_VERSION) +#define BOT_COMMAND_MASK_STATS (1UL << BOT_COMMAND_STATS) +#define BOT_COMMAND_MASK_MAGIC8 (1UL << BOT_COMMAND_MAGIC8) +#define BOT_COMMAND_MASK_PATH (1UL << BOT_COMMAND_PATH) +#define BOT_COMMAND_MASK_TRACE (1UL << BOT_COMMAND_TRACE) +#define BOT_COMMAND_MASK_TRACER (1UL << BOT_COMMAND_TRACER) +#define BOT_COMMAND_MASK_PREFIX (1UL << BOT_COMMAND_PREFIX) +#define BOT_COMMAND_MASK_ALL (BOT_COMMAND_MASK_HELP | BOT_COMMAND_MASK_CMD | BOT_COMMAND_MASK_PING | \ + BOT_COMMAND_MASK_TEST | BOT_COMMAND_MASK_HELLO | BOT_COMMAND_MASK_ABOUT | \ + BOT_COMMAND_MASK_ROLL | BOT_COMMAND_MASK_DICE | BOT_COMMAND_MASK_STATUS | \ + BOT_COMMAND_MASK_CHANNELS | BOT_COMMAND_MASK_VERSION | BOT_COMMAND_MASK_STATS | \ + BOT_COMMAND_MASK_MAGIC8 | BOT_COMMAND_MASK_PATH | BOT_COMMAND_MASK_TRACE | \ + BOT_COMMAND_MASK_TRACER | BOT_COMMAND_MASK_PREFIX) + +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, + BOT_WRITE_NO_SPACE +}; + +enum BotCoordinatorScheduleResult : uint8_t { + BOT_COORDINATOR_SCHEDULED = 0, + BOT_COORDINATOR_REPLACED, + BOT_COORDINATOR_NO_SPACE, + BOT_COORDINATOR_NOT_NORMAL +}; + +enum BotCoordinatorReadyResult : uint8_t { + BOT_COORDINATOR_READY_NONE = 0, + BOT_COORDINATOR_READY_SEND, + BOT_COORDINATOR_READY_SUPPRESSED, + BOT_COORDINATOR_READY_EXPIRED +}; + +struct BotFingerprint { + uint64_t value; +}; + +struct BotMessage { + BotChannelKind channel_kind; + char channel_name[BOT_MAX_CHANNEL_NAME_LEN + 1]; + char sender_name[BOT_MAX_SENDER_NAME_LEN + 1]; + uint8_t sender_key_prefix[BOT_SENDER_KEY_PREFIX_LEN]; + uint8_t sender_key_prefix_len; + bool text_truncated; + uint32_t sender_timestamp; + uint8_t path_len; + uint8_t path_hash_size; + uint8_t path_hash_count; + int8_t packet_snr_quarters; + char text[BOT_MAX_TEXT_LEN + 1]; + size_t text_len; + const uint8_t* path; +}; + +struct BotCommand { + BotCommandId id; + char name[BOT_MAX_COMMAND_NAME_LEN + 1]; + char args[BOT_MAX_COMMAND_ARGS_LEN + 1]; + size_t args_len; +}; + +struct BotResponse { + BotPolicyDecision decision; + BotFingerprint fingerprint; + char text[BOT_MAX_RESPONSE_LEN + 1]; + size_t text_len; + bool truncated; +}; + +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]; + char firmware_version[BOT_MAX_FIRMWARE_VERSION_LEN + 1]; + char firmware_build_date[BOT_MAX_BUILD_DATE_LEN + 1]; + uint32_t uptime_seconds; + uint16_t battery_millivolts; + uint32_t storage_used_kb; + 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 suppressed_responses; + uint32_t pending_responses; + uint32_t emergency_forwards; + uint32_t emergency_forward_failures; + uint32_t packets_recv; + uint32_t packets_sent; + uint32_t packets_recv_errors; + uint32_t flood_recv; + uint32_t flood_sent; + uint32_t direct_recv; + uint32_t direct_sent; + uint32_t tx_airtime_seconds; + uint32_t rx_airtime_seconds; + uint32_t random_seed; + int16_t noise_floor; + int8_t last_rssi; + int8_t last_snr_quarters; + uint8_t queue_depth; + uint8_t channel_count; + uint8_t path_len; + uint8_t path_hash_size; + uint8_t path_hash_count; + int8_t path_snr_quarters; + const uint8_t* path; +}; + +struct BotCommandResult { + BotCommandResultCode code; + size_t text_len; +}; + +struct BotCommandCooldown { + BotCommandId command_id; + uint32_t expires_at_millis; +}; + +struct BotKnownBotEntry { + bool active; + uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]; + uint8_t flags; + char label[BOT_KNOWN_BOT_LABEL_LEN]; +}; + +struct BotCoordinatorPending { + bool active; + bool suppressed; + BotFingerprint request_fingerprint; + BotFingerprint response_fingerprint; + uint32_t due_at_millis; + uint32_t expires_at_millis; +}; + +struct BotCoordinatorRecent { + bool active; + BotFingerprint response_fingerprint; + uint32_t expires_at_millis; +}; + +struct BotCoordinatorReady { + BotCoordinatorReadyResult result; + BotFingerprint request_fingerprint; + BotFingerprint response_fingerprint; +}; + +struct BotEmergencyForward { + uint8_t part_count; + bool truncated; + char parts[BOT_EMERGENCY_MAX_PARTS][BOT_MAX_GROUP_RESPONSE_LEN + 1]; + size_t part_lens[BOT_EMERGENCY_MAX_PARTS]; +}; + +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 { + uint32_t observed_messages; + uint32_t ignored_messages; + uint32_t eligible_messages; + uint32_t emergency_messages; + uint32_t emergency_forwards; + uint32_t emergency_forward_failures; + uint32_t parse_errors; + uint32_t pending_responses; + uint32_t suppressed_responses; + uint32_t expired_responses; + uint32_t known_bot_messages; + uint32_t sent_messages; + uint32_t send_failures; +}; + +static_assert(BOT_COMMAND_UNKNOWN < 32, "BotCommandId must fit uint32_t command masks"); +static_assert(sizeof(BotMessage) <= 264, "BotMessage RAM budget exceeded"); +static_assert(sizeof(BotCommand) <= 120, "BotCommand RAM budget exceeded"); +static_assert(sizeof(BotResponse) <= 184, "BotResponse RAM budget exceeded"); +static_assert(sizeof(BotCommandContext) <= 312, "BotCommandContext RAM budget exceeded"); +static_assert(sizeof(BotCommandResult) <= 16, "BotCommandResult RAM budget exceeded"); +static_assert(sizeof(BotCommandCooldown) <= 8, "BotCommandCooldown RAM budget exceeded"); +static_assert(sizeof(BotKnownBotEntry) <= 24, "BotKnownBotEntry RAM budget exceeded"); +static_assert(sizeof(BotCoordinatorPending) <= 32, "BotCoordinatorPending RAM budget exceeded"); +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) <= 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/EmergencyForwarder.cpp b/examples/companion_radio/EmergencyForwarder.cpp new file mode 100644 index 00000000..b228a8fa --- /dev/null +++ b/examples/companion_radio/EmergencyForwarder.cpp @@ -0,0 +1,118 @@ +#include "EmergencyForwarder.h" + +#include +#include + +namespace { + +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; +} + +bool prefixEqual(const char* text, size_t text_len, const char* prefix) { + size_t prefix_len = strlen(prefix); + if (text_len < prefix_len) return false; + for (size_t i = 0; i < prefix_len; i++) { + if (text[i] != prefix[i]) return false; + } + return true; +} + +size_t appendText(char* output, size_t output_len, size_t pos, const char* text, size_t text_len) { + if (!output || output_len == 0) return 0; + while (pos + 1 < output_len && text_len > 0) { + output[pos++] = *text++; + text_len--; + } + output[pos] = 0; + return pos; +} + +size_t appendRepeated(char* output, size_t output_len, size_t pos, char ch, size_t count) { + while (pos + 1 < output_len && count > 0) { + output[pos++] = ch; + count--; + } + output[pos] = 0; + return pos; +} + +void writePart(BotEmergencyForward& forward, uint8_t part_idx, const char* header, size_t header_len, + const char* text, size_t text_len, bool multipart) { + char* output = forward.parts[part_idx]; + size_t output_len = sizeof(forward.parts[part_idx]); + output[0] = 0; + size_t pos = appendText(output, output_len, 0, header, header_len); + + if (multipart) { + char marker[8]; + int n = snprintf(marker, sizeof(marker), "[%u/%u] ", (unsigned)(part_idx + 1), (unsigned)forward.part_count); + if (n > 0) pos = appendText(output, output_len, pos, marker, (size_t)n); + } + + pos = appendText(output, output_len, pos, text, text_len); + forward.part_lens[part_idx] = pos; +} + +} + +namespace EmergencyForwarder { + +bool isForwardedEmergencyText(const char* text, size_t text_len) { + if (!text) return false; + size_t len = boundedStrLen(text, text_len); + return prefixEqual(text, len, BOT_EMERGENCY_PREFIX); +} + +bool format(const BotMessage& message, BotEmergencyForward& forward) { + memset(&forward, 0, sizeof(forward)); + if (message.channel_kind != BOT_CHANNEL_EMERGENCY) return false; + if (isForwardedEmergencyText(message.text, message.text_len)) return false; + + char header[BOT_MAX_GROUP_RESPONSE_LEN + 1]; + const char* sender = message.sender_name[0] ? message.sender_name : "unknown"; + int header_len_int = snprintf(header, sizeof(header), BOT_EMERGENCY_PREFIX "%s: ", sender); + if (header_len_int < 0) return false; + size_t header_len = (size_t)header_len_int; + if (header_len >= sizeof(header)) header_len = sizeof(header) - 1; + if (header_len >= BOT_MAX_GROUP_RESPONSE_LEN) return false; + + size_t text_len = boundedStrLen(message.text, message.text_len); + size_t one_part_capacity = BOT_MAX_GROUP_RESPONSE_LEN - header_len; + if (text_len <= one_part_capacity) { + forward.part_count = 1; + forward.truncated = message.text_truncated; + writePart(forward, 0, header, header_len, message.text, text_len, false); + return true; + } + + size_t multipart_header_extra = 6; + if (header_len + multipart_header_extra >= BOT_MAX_GROUP_RESPONSE_LEN) return false; + size_t part_capacity = BOT_MAX_GROUP_RESPONSE_LEN - header_len - multipart_header_extra; + size_t needed_parts = (text_len + part_capacity - 1) / part_capacity; + forward.part_count = needed_parts > BOT_EMERGENCY_MAX_PARTS ? BOT_EMERGENCY_MAX_PARTS : (uint8_t)needed_parts; + forward.truncated = message.text_truncated || needed_parts > BOT_EMERGENCY_MAX_PARTS; + + size_t offset = 0; + for (uint8_t i = 0; i < forward.part_count; i++) { + size_t chunk_len = text_len - offset; + if (chunk_len > part_capacity) chunk_len = part_capacity; + writePart(forward, i, header, header_len, &message.text[offset], chunk_len, true); + offset += chunk_len; + } + + if (forward.truncated && forward.part_count > 0) { + uint8_t last = forward.part_count - 1; + size_t pos = forward.part_lens[last]; + if (pos > 3) pos -= 3; + forward.parts[last][pos] = 0; + pos = appendRepeated(forward.parts[last], sizeof(forward.parts[last]), pos, '.', 3); + forward.part_lens[last] = pos; + } + + return forward.part_count > 0; +} + +} diff --git a/examples/companion_radio/EmergencyForwarder.h b/examples/companion_radio/EmergencyForwarder.h new file mode 100644 index 00000000..e59d29e2 --- /dev/null +++ b/examples/companion_radio/EmergencyForwarder.h @@ -0,0 +1,10 @@ +#pragma once + +#include "BotTypes.h" + +namespace EmergencyForwarder { + +bool isForwardedEmergencyText(const char* text, size_t text_len); +bool format(const BotMessage& message, BotEmergencyForward& forward); + +} diff --git a/examples/companion_radio/FirmwareBot.cpp b/examples/companion_radio/FirmwareBot.cpp new file mode 100644 index 00000000..d1b240bb --- /dev/null +++ b/examples/companion_radio/FirmwareBot.cpp @@ -0,0 +1,272 @@ +#include "FirmwareBot.h" + +#include "BotCommandRegistry.h" + +#include +#include + +namespace { + +uint64_t fnv1aUpdate(uint64_t hash, uint8_t value) { + hash ^= value; + hash *= 1099511628211ULL; + return hash; +} + +uint64_t fnv1aUpdateBytes(uint64_t hash, const uint8_t* data, size_t len) { + for (size_t i = 0; i < len; i++) { + hash = fnv1aUpdate(hash, data[i]); + } + return hash; +} + +uint64_t fnv1aUpdateTextLower(uint64_t hash, const char* value, size_t len) { + for (size_t i = 0; i < len; i++) { + hash = fnv1aUpdate(hash, (uint8_t)tolower((unsigned char)value[i])); + } + return hash; +} + +size_t boundedStrLen(const char* value, size_t max_len); + +uint64_t fnv1aUpdateChannel(uint64_t hash, const BotMessage& message) { + hash = fnv1aUpdate(hash, (uint8_t)message.channel_kind); + const char* channel_name = message.channel_name; + size_t channel_name_len = boundedStrLen(message.channel_name, sizeof(message.channel_name)); + if (channel_name_len > 0 && channel_name[0] == '#') { + channel_name++; + channel_name_len--; + } + return fnv1aUpdateTextLower(hash, channel_name, channel_name_len); +} + +bool isSpaceByte(char ch) { + return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n'; +} + +bool isControlByte(char ch) { + unsigned char value = (unsigned char)ch; + return value < 0x20 || value == 0x7F; +} + +bool isCommandDelimiter(char ch) { + return ch == ' ' || ch == ':' || ch == ',' || ch == '.' || ch == ';' || ch == '?' || ch == '!'; +} + +uint64_t fnv1aUpdateU32(uint64_t hash, uint32_t value) { + hash = fnv1aUpdate(hash, (uint8_t)(value & 0xFF)); + hash = fnv1aUpdate(hash, (uint8_t)((value >> 8) & 0xFF)); + hash = fnv1aUpdate(hash, (uint8_t)((value >> 16) & 0xFF)); + hash = fnv1aUpdate(hash, (uint8_t)((value >> 24) & 0xFF)); + return hash; +} + +size_t boundedStrLen(const char* value, size_t max_len) { + size_t len = 0; + while (len < max_len && value[len] != 0) len++; + return len; +} + +} + +namespace FirmwareBot { + +BotWriteResult normalizeText(const char* input, size_t input_len, char* output, size_t output_len, size_t* written) { + if (written) *written = 0; + if (!output || output_len == 0) return BOT_WRITE_NO_SPACE; + + size_t out = 0; + bool pending_space = false; + bool truncated = false; + + for (size_t i = 0; i < input_len; i++) { + char ch = input ? input[i] : 0; + if (ch == 0) break; + + if (isSpaceByte(ch) || isControlByte(ch)) { + pending_space = out > 0; + continue; + } + + if (pending_space) { + if (out + 1 >= output_len) { + truncated = true; + break; + } + output[out++] = ' '; + pending_space = false; + } + + if (out + 1 >= output_len) { + truncated = true; + break; + } + output[out++] = ch; + } + + output[out] = 0; + if (written) *written = out; + return truncated ? BOT_WRITE_TRUNCATED : BOT_WRITE_OK; +} + +BotCommandId commandIdForName(const char* name, size_t len) { + const BotCommandMetadata* command = BotCommandRegistry::findByName(name, len); + return command ? command->id : 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) { + return parseCommand(text, text_len, command, false); +} + +bool parseCommand(const char* text, size_t text_len, BotCommand* command, bool allow_prefixless) { + if (!command) return false; + memset(command, 0, sizeof(*command)); + command->id = BOT_COMMAND_NONE; + + char normalized[BOT_MAX_TEXT_LEN + 1]; + size_t normalized_len = 0; + normalizeText(text, text_len, normalized, sizeof(normalized), &normalized_len); + + if (normalized_len == 0) return false; + + bool has_prefix = normalized[0] == '!' || normalized[0] == '/'; + if (!has_prefix && !allow_prefixless) return false; + if (has_prefix && normalized_len < 2) return false; + + size_t pos = has_prefix ? 1 : 0; + while (pos < normalized_len && normalized[pos] == ' ') pos++; + size_t name_start = pos; + while (pos < normalized_len && !isCommandDelimiter(normalized[pos])) pos++; + size_t name_len = pos - name_start; + if (name_len == 0) return false; + + size_t copy_name_len = name_len; + if (copy_name_len > BOT_MAX_COMMAND_NAME_LEN) copy_name_len = BOT_MAX_COMMAND_NAME_LEN; + for (size_t i = 0; i < copy_name_len; i++) { + command->name[i] = (char)tolower((unsigned char)normalized[name_start + i]); + } + command->name[copy_name_len] = 0; + const BotCommandMetadata* metadata = name_len > BOT_MAX_COMMAND_NAME_LEN ? NULL : BotCommandRegistry::findByName(command->name, copy_name_len); + command->id = metadata ? metadata->id : BOT_COMMAND_UNKNOWN; + if (!has_prefix && (!metadata || metadata->visibility != BOT_COMMAND_VISIBILITY_DISCOVERABLE)) return false; + + while (pos < normalized_len && isCommandDelimiter(normalized[pos])) pos++; + size_t args_len = normalized_len - pos; + if (args_len > BOT_MAX_COMMAND_ARGS_LEN) args_len = BOT_MAX_COMMAND_ARGS_LEN; + if (args_len > 0) memcpy(command->args, &normalized[pos], args_len); + command->args[args_len] = 0; + command->args_len = args_len; + + 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; + + if (!text && text_len > 0) { + output[0] = 0; + return BOT_WRITE_NO_SPACE; + } + + 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; + if (written) *written = copy_len; + + return copy_len < text_len ? BOT_WRITE_TRUNCATED : BOT_WRITE_OK; +} + +BotFingerprint fingerprintFor(const BotMessage& message) { + uint64_t hash = 1469598103934665603ULL; + hash = fnv1aUpdateChannel(hash, message); + hash = fnv1aUpdateBytes(hash, message.sender_key_prefix, sizeof(message.sender_key_prefix)); + hash = fnv1aUpdateTextLower(hash, message.sender_name, boundedStrLen(message.sender_name, sizeof(message.sender_name))); + hash = fnv1aUpdateU32(hash, message.sender_timestamp); + + char normalized[BOT_MAX_TEXT_LEN + 1]; + size_t normalized_len = 0; + normalizeText(message.text, message.text_len, normalized, sizeof(normalized), &normalized_len); + hash = fnv1aUpdateTextLower(hash, normalized, normalized_len); + + BotFingerprint fingerprint = { hash }; + return fingerprint; +} + +BotFingerprint responseFingerprintFor(const BotMessage& message, const char* response_text, size_t response_text_len) { + uint64_t hash = 1469598103934665603ULL; + hash = fnv1aUpdateChannel(hash, message); + if (message.channel_kind == BOT_CHANNEL_DM) { + hash = fnv1aUpdate(hash, message.sender_key_prefix_len); + hash = fnv1aUpdateBytes(hash, message.sender_key_prefix, message.sender_key_prefix_len); + } + + char normalized[BOT_MAX_RESPONSE_LEN + 1]; + size_t normalized_len = 0; + normalizeText(response_text, response_text_len, normalized, sizeof(normalized), &normalized_len); + hash = fnv1aUpdateTextLower(hash, normalized, normalized_len); + + BotFingerprint fingerprint = { hash }; + return fingerprint; +} + +} diff --git a/examples/companion_radio/FirmwareBot.h b/examples/companion_radio/FirmwareBot.h new file mode 100644 index 00000000..e248adc1 --- /dev/null +++ b/examples/companion_radio/FirmwareBot.h @@ -0,0 +1,22 @@ +#pragma once + +#include "BotTypes.h" + +namespace FirmwareBot { + +BotWriteResult normalizeText(const char* input, size_t input_len, char* output, size_t output_len, size_t* written); +bool parseCommand(const char* text, size_t text_len, BotCommand* command); +bool parseCommand(const char* text, size_t text_len, BotCommand* command, bool allow_prefixless); +bool splitChannelText(const char* text, size_t text_len, char* sender, size_t sender_len, const char** body, + size_t* body_len); +BotWriteResult writeResponse(char* output, size_t output_len, const char* text, size_t text_len, size_t* written); +BotFingerprint fingerprintFor(const BotMessage& message); +BotFingerprint responseFingerprintFor(const BotMessage& message, const char* response_text, size_t response_text_len); +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/KnownBotRegistry.cpp b/examples/companion_radio/KnownBotRegistry.cpp new file mode 100644 index 00000000..a3e764ee --- /dev/null +++ b/examples/companion_radio/KnownBotRegistry.cpp @@ -0,0 +1,87 @@ +#include "KnownBotRegistry.h" + +#include + +namespace { + +bool keyEqual(const uint8_t a[BOT_SENDER_KEY_PREFIX_LEN], const uint8_t b[BOT_SENDER_KEY_PREFIX_LEN]) { + return memcmp(a, b, BOT_SENDER_KEY_PREFIX_LEN) == 0; +} + +bool keyPrefixEqual(const uint8_t a[BOT_SENDER_KEY_PREFIX_LEN], const uint8_t b[BOT_SENDER_KEY_PREFIX_LEN], size_t len) { + return memcmp(a, b, len) == 0; +} + +void copyLabel(char dest[12], const char* label) { + size_t i = 0; + if (label) { + while (i + 1 < 12 && label[i] != 0) { + dest[i] = label[i]; + i++; + } + } + dest[i] = 0; +} + +} + +namespace KnownBotRegistry { + +void clear(BotKnownBotEntry entries[], size_t entry_count) { + if (!entries) return; + memset(entries, 0, sizeof(BotKnownBotEntry) * entry_count); +} + +const BotKnownBotEntry* find(const BotKnownBotEntry entries[], size_t entry_count, + const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], size_t key_prefix_len) { + if (!entries || !key_prefix || key_prefix_len < BOT_MIN_AUTH_SENDER_KEY_PREFIX_LEN) return NULL; + if (key_prefix_len > BOT_SENDER_KEY_PREFIX_LEN) key_prefix_len = BOT_SENDER_KEY_PREFIX_LEN; + + const BotKnownBotEntry* match = NULL; + for (size_t i = 0; i < entry_count; i++) { + if (!entries[i].active || !keyPrefixEqual(entries[i].key_prefix, key_prefix, key_prefix_len)) continue; + if (match) return NULL; + match = &entries[i]; + } + return match; +} + +bool add(BotKnownBotEntry entries[], size_t entry_count, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], + uint8_t flags, const char* label) { + if (!entries || !key_prefix || entry_count == 0) return false; + + size_t slot = entry_count; + for (size_t i = 0; i < entry_count; i++) { + if (entries[i].active && keyEqual(entries[i].key_prefix, key_prefix)) { + slot = i; + break; + } + if (slot == entry_count && !entries[i].active) slot = i; + } + if (slot == entry_count) return false; + + entries[slot].active = true; + memcpy(entries[slot].key_prefix, key_prefix, BOT_SENDER_KEY_PREFIX_LEN); + entries[slot].flags = flags; + copyLabel(entries[slot].label, label); + return true; +} + +bool remove(BotKnownBotEntry entries[], size_t entry_count, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]) { + if (!entries || !key_prefix) return false; + for (size_t i = 0; i < entry_count; i++) { + if (entries[i].active && keyEqual(entries[i].key_prefix, key_prefix)) { + memset(&entries[i], 0, sizeof(entries[i])); + return true; + } + } + return false; +} + +bool canSuppressNormal(const BotKnownBotEntry entries[], size_t entry_count, + const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], size_t key_prefix_len) { + const BotKnownBotEntry* entry = find(entries, entry_count, key_prefix, key_prefix_len); + return entry && (entry->flags & BOT_KNOWN_BOT_FLAG_SUPPRESS_NORMAL) != 0; +} + +} diff --git a/examples/companion_radio/KnownBotRegistry.h b/examples/companion_radio/KnownBotRegistry.h new file mode 100644 index 00000000..5e93dad0 --- /dev/null +++ b/examples/companion_radio/KnownBotRegistry.h @@ -0,0 +1,16 @@ +#pragma once + +#include "BotTypes.h" + +namespace KnownBotRegistry { + +void clear(BotKnownBotEntry entries[], size_t entry_count); +bool add(BotKnownBotEntry entries[], size_t entry_count, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], + uint8_t flags, const char* label); +bool remove(BotKnownBotEntry entries[], size_t entry_count, const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN]); +const BotKnownBotEntry* find(const BotKnownBotEntry entries[], size_t entry_count, + const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], size_t key_prefix_len); +bool canSuppressNormal(const BotKnownBotEntry entries[], size_t entry_count, + const uint8_t key_prefix[BOT_SENDER_KEY_PREFIX_LEN], size_t key_prefix_len); + +} diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index e8c1914b..44fcd1ca 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2,6 +2,21 @@ #include // needed for PlatformIO #include +#include +#include +#include +#include + +#if CMESH_BOT_ENABLED +#include "BotCommandRegistry.h" +#include "BotCommands.h" +#include "BotPolicy.h" +#include "BotPrefs.h" +#include "EmergencyForwarder.h" +#include "FirmwareBot.h" +#include "KnownBotRegistry.h" +#include "ResponseCoordinator.h" +#endif #define CMD_APP_START 1 #define CMD_SEND_TXT_MSG 2 @@ -105,8 +120,244 @@ #define DIRECT_SEND_PERHOP_EXTRA_MILLIS 250 #define LAZY_CONTACTS_WRITE_DELAY 5000 +#if CMESH_BOT_ENABLED +#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; +} + +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; +} + +static int botHexValue(char ch) { + if (ch >= '0' && ch <= '9') return ch - '0'; + if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10; + if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10; + return -1; +} + +static bool botTraceFlagForHashSize(uint8_t hash_size, uint8_t *flags) { + if (!flags) return false; + if (hash_size == 1) { + *flags = 0; + return true; + } + if (hash_size == 2) { + *flags = 1; + return true; + } + if (hash_size == 4) { + *flags = 2; + return true; + } + return false; +} + +static uint8_t botConfiguredTraceHashSize(uint8_t path_hash_mode) { + if (path_hash_mode == 0) return 1; + if (path_hash_mode == 1) return 2; + return 4; +} + +static uint8_t botTraceHashSize(uint8_t flags) { + return (uint8_t)(1U << (flags & 0x03)); +} + +static bool botTracePathShapeValid(uint8_t path_len, uint8_t flags) { + uint8_t hash_size = botTraceHashSize(flags); + return path_len > 0 && path_len <= BOT_MAX_PATH_BYTES && path_len + 9 <= MAX_PACKET_PAYLOAD && + (path_len % hash_size) == 0 && (path_len / hash_size) <= MAX_PATH_SIZE; +} + +static void botCopyReversedPath(uint8_t *dest, const uint8_t *src, uint8_t hop_count, uint8_t hash_size) { + for (uint8_t i = 0; i < hop_count; i++) { + memcpy(&dest[i * hash_size], &src[(hop_count - i - 1) * hash_size], hash_size); + } +} + +static bool botParseTraceHexPath(const BotCommand &command, uint8_t flags, uint8_t path[BOT_MAX_PATH_BYTES], + uint8_t *path_len) { + if (!path || !path_len || command.args_len == 0) return false; + if ((command.args_len & 1) != 0 || command.args_len / 2 > BOT_MAX_PATH_BYTES) return false; + + uint8_t parsed_len = (uint8_t)(command.args_len / 2); + if (!botTracePathShapeValid(parsed_len, flags)) return false; + + for (uint8_t i = 0; i < parsed_len; i++) { + int high = botHexValue(command.args[i * 2]); + int low = botHexValue(command.args[i * 2 + 1]); + if (high < 0 || low < 0) return false; + path[i] = (uint8_t)((high << 4) | low); + } + *path_len = parsed_len; + return true; +} + +static BotCommandResult botCommandResult(BotCommandResultCode code, size_t text_len) { + BotCommandResult result = { code, text_len }; + return result; +} + +static BotCommandResult botWriteText(char *output, size_t output_len, const char *text) { + if (!output || output_len == 0) return botCommandResult(BOT_COMMAND_RESULT_NO_SPACE, 0); + size_t text_len = botBoundedStrLen(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 botCommandResult(copy_len < text_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, copy_len); +} + +static BotCommandResult botWriteFormatted(char *output, size_t output_len, const char *format, ...) { + if (!output || output_len == 0) return botCommandResult(BOT_COMMAND_RESULT_NO_SPACE, 0); + va_list args; + va_start(args, format); + int written = vsnprintf(output, output_len, format, args); + va_end(args); + if (written < 0) { + output[0] = 0; + return botCommandResult(BOT_COMMAND_RESULT_NO_SPACE, 0); + } + size_t actual = (size_t)written; + if (actual >= output_len) actual = output_len - 1; + return botCommandResult((size_t)written >= output_len ? BOT_COMMAND_RESULT_TRUNCATED : BOT_COMMAND_RESULT_OK, actual); +} + +static bool botParsePubKeyPrefixHex(const BotCommand &command, uint8_t prefix[PUB_KEY_SIZE], uint8_t *prefix_len) { + if (!prefix || !prefix_len || command.args_len == 0 || (command.args_len & 1) != 0) return false; + size_t byte_len = command.args_len / 2; + if (byte_len < BOT_MIN_AUTH_SENDER_KEY_PREFIX_LEN || byte_len > PUB_KEY_SIZE) return false; + for (size_t i = 0; i < byte_len; i++) { + int high = botHexValue(command.args[i * 2]); + int low = botHexValue(command.args[i * 2 + 1]); + if (high < 0 || low < 0) return false; + prefix[i] = (uint8_t)((high << 4) | low); + } + *prefix_len = (uint8_t)byte_len; + return true; +} + +static void botFormatKeyPrefixHex(const uint8_t *key, char *output, size_t output_len) { + static const char hex[] = "0123456789abcdef"; + if (!output || output_len == 0) return; + if (!key || 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[i] >> 4]; + output[i * 2 + 1] = hex[key[i] & 0x0F]; + } + output[BOT_SENDER_KEY_PREFIX_LEN * 2] = 0; +} + +static void botCopyContactName(const ContactInfo &contact, char *output, size_t output_len) { + if (!output || output_len == 0) return; + size_t len = botBoundedStrLen(contact.name, sizeof(contact.name)); + if (len == 0) { + botCopyString(output, output_len, "contact"); + return; + } + if (len + 1 > output_len) len = output_len - 1; + memcpy(output, contact.name, len); + output[len] = 0; +} + +static void botFormatQuarters(int8_t quarters, char *output, size_t output_len) { + if (!output || output_len == 0) return; + int value = quarters; + const char *sign = value < 0 ? "-" : ""; + if (value < 0) value = -value; + snprintf(output, output_len, "%s%d.%02d", sign, value / 4, (value % 4) * 25); +} + +static void botAppendHex(char *output, size_t output_len, size_t *pos, const uint8_t *data, size_t data_len) { + static const char hex[] = "0123456789abcdef"; + for (size_t i = 0; i < data_len; i++) { + if (*pos + 2 < output_len) { + output[*pos] = hex[data[i] >> 4]; + output[*pos + 1] = hex[data[i] & 0x0F]; + } + *pos += 2; + } + if (output_len > 0) output[*pos < output_len ? *pos : output_len - 1] = 0; +} + +static size_t botFormatTraceSent(char *output, size_t output_len) { + if (!output || output_len == 0) return 0; + const char *text = "Trace sent"; + size_t len = botBoundedStrLen(text, BOT_MAX_RESPONSE_LEN + 1); + if (len + 1 > output_len) len = output_len - 1; + memcpy(output, text, len); + output[len] = 0; + return len; +} + +static size_t botFormatTraceResult(char *output, size_t output_len, uint32_t tag, uint8_t flags, + const uint8_t *path_hashes, uint8_t path_len, int8_t snr_quarters) { + if (!output || output_len == 0) return 0; + uint8_t hash_size = botTraceHashSize(flags); + uint8_t hop_count = path_len / hash_size; + char snr[8]; + botFormatQuarters(snr_quarters, snr, sizeof(snr)); + int written = snprintf(output, output_len, "Trace %08lx %uh x %uB snr %s: ", (unsigned long)tag, + (unsigned)hop_count, (unsigned)hash_size, snr); + if (written < 0) { + output[0] = 0; + return 0; + } + size_t pos = (size_t)written; + botAppendHex(output, output_len, &pos, path_hashes, path_len); + return botBoundedStrLen(output, output_len); +} +#endif + // these are _pushed_ to client app at any time #define PUSH_CODE_ADVERT 0x80 #define PUSH_CODE_PATH_UPDATED 0x81 @@ -514,6 +765,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 +782,840 @@ 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::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)); + memset(pending_bot_traces, 0, sizeof(pending_bot_traces)); + 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 (size_t i = 0; i < BotCommandRegistry::commandCount(); i++) { + const BotCommandMetadata *command = BotCommandRegistry::commandAt(i); + if (!command || command->visibility != BOT_COMMAND_VISIBILITY_DISCOVERABLE) continue; + Serial.printf(" > %s %s\n", command->name, + 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, 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); + if (sender_prefix && prefix_len > 0) memcpy(message.sender_key_prefix, sender_prefix, prefix_len); + message.sender_key_prefix_len = prefix_len; + message.sender_timestamp = sender_timestamp; + message.text_truncated = FirmwareBot::normalizeText(text, botBoundedStrLen(text, BOT_MAX_TEXT_LEN + 1), message.text, + sizeof(message.text), &message.text_len) == BOT_WRITE_TRUNCATED; + recordBotObservation(message, &from, 0xFF); +} + +void MyMesh::observeBotChannelMessage(uint8_t channel_idx, const char *channel_name, const char *text, + uint32_t sender_timestamp, const mesh::Packet *packet) { + BotMessage message; + memset(&message, 0, sizeof(message)); + size_t channel_len = botBoundedStrLen(channel_name, BOT_MAX_CHANNEL_NAME_LEN); + 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; + } + message.sender_timestamp = sender_timestamp; + if (packet && packet->isRouteFlood() && packet->path_len <= 0xFF && mesh::Packet::isValidPathLen((uint8_t)packet->path_len)) { + message.path_len = (uint8_t)packet->path_len; + message.path_hash_size = packet->getPathHashSize(); + message.path_hash_count = packet->getPathHashCount(); + message.packet_snr_quarters = (int8_t)(packet->getSNR() * 4); + message.path = packet->path; + } + + const char *body = text; + size_t body_len = botBoundedStrLen(text, BOT_MAX_TEXT_LEN); + FirmwareBot::splitChannelText(text, body_len, message.sender_name, sizeof(message.sender_name), &body, &body_len); + message.text_truncated = FirmwareBot::normalizeText(body, body_len, message.text, sizeof(message.text), + &message.text_len) == BOT_WRITE_TRUNCATED; + 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)); + 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; + context.eligible_messages = bot_stats.eligible_messages; + context.sent_messages = bot_stats.sent_messages; + context.send_failures = bot_stats.send_failures; + context.suppressed_responses = bot_stats.suppressed_responses; + context.pending_responses = bot_stats.pending_responses; + context.emergency_forwards = bot_stats.emergency_forwards; + context.emergency_forward_failures = bot_stats.emergency_forward_failures; + if (command_id == BOT_COMMAND_ROLL || command_id == BOT_COMMAND_DICE || command_id == BOT_COMMAND_MAGIC8) { + getRNG()->random((uint8_t *)&context.random_seed, sizeof(context.random_seed)); + } + if (command_id == BOT_COMMAND_STATUS || command_id == BOT_COMMAND_STATS) { + 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++; + } + } + if (command_id == BOT_COMMAND_VERSION) { + StrHelper::strzcpy(context.firmware_version, FIRMWARE_VERSION, sizeof(context.firmware_version)); + StrHelper::strzcpy(context.firmware_build_date, FIRMWARE_BUILD_DATE, sizeof(context.firmware_build_date)); + } + if (command_id == BOT_COMMAND_STATS) { + context.queue_depth = (uint8_t)_mgr->getOutboundTotal(); + context.noise_floor = (int16_t)_radio->getNoiseFloor(); + context.last_rssi = (int8_t)radio_driver.getLastRSSI(); + context.last_snr_quarters = (int8_t)(radio_driver.getLastSNR() * 4); + context.tx_airtime_seconds = getTotalAirTime() / 1000; + context.rx_airtime_seconds = getReceiveAirTime() / 1000; + context.packets_recv = radio_driver.getPacketsRecv(); + context.packets_sent = radio_driver.getPacketsSent(); + context.flood_sent = getNumSentFlood(); + context.direct_sent = getNumSentDirect(); + context.flood_recv = getNumRecvFlood(); + context.direct_recv = getNumRecvDirect(); + context.packets_recv_errors = radio_driver.getPacketsRecvErrors(); + } +} + +bool MyMesh::enqueueBotResponse(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, + const char *text, size_t text_len, BotFingerprint request_fingerprint, + BotFingerprint response_fingerprint) { + size_t slot = BOT_PENDING_RESPONSE_SLOTS; + for (size_t i = 0; i < BOT_PENDING_RESPONSE_SLOTS; i++) { + if (pending_bot_responses[i].active && pending_bot_responses[i].request_fingerprint.value == request_fingerprint.value) { + slot = i; + break; + } + if (slot == BOT_PENDING_RESPONSE_SLOTS && !pending_bot_responses[i].active) slot = i; + } + if (slot == BOT_PENDING_RESPONSE_SLOTS) return false; + + PendingBotResponse *pending = &pending_bot_responses[slot]; + 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->request_fingerprint = request_fingerprint; + pending->response_fingerprint = response_fingerprint; + 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; } +bool MyMesh::findBotChannel(BotChannelKind kind, uint8_t &channel_idx) { + for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) { + 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, bot_prefs) == kind) { + channel_idx = i; + return true; + } + } + } + return false; +} + +BotCommandResult MyMesh::executeBotPrefixCommand(const BotCommand &command, char *output, size_t output_len) { + uint8_t prefix[PUB_KEY_SIZE]; + uint8_t prefix_len = 0; + if (!botParsePubKeyPrefixHex(command, prefix, &prefix_len)) { + return botWriteText(output, output_len, "Usage: prefix <8+ even hex>"); + } + + ContactInfo match; + bool have_match = false; + uint8_t match_count = 0; + for (uint32_t i = 0; i < (uint32_t)getNumContacts(); i++) { + ContactInfo contact; + if (!getContactByIdx(i, contact)) continue; + if (memcmp(contact.id.pub_key, prefix, prefix_len) != 0) continue; + if (!have_match) { + match = contact; + have_match = true; + } + if (match_count < 2) match_count++; + } + + if (!have_match) return botWriteText(output, output_len, "Prefix no match"); + if (match_count > 1) return botWriteText(output, output_len, "Prefix ambiguous"); + + char name[sizeof(match.name)]; + char key_hex[BOT_SENDER_KEY_PREFIX_LEN * 2 + 1]; + botCopyContactName(match, name, sizeof(name)); + botFormatKeyPrefixHex(match.id.pub_key, key_hex, sizeof(key_hex)); + return botWriteFormatted(output, output_len, "Prefix %s %s", key_hex, name); +} + +bool MyMesh::handleBotTraceCommand(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, + const BotCommand &command) { + uint8_t hash_size = command.args_len > 0 ? botConfiguredTraceHashSize(_prefs.path_hash_mode) : message.path_hash_size; + if (hash_size == 0) hash_size = botConfiguredTraceHashSize(_prefs.path_hash_mode); + uint8_t flags = 0; + if (!botTraceFlagForHashSize(hash_size, &flags)) return false; + + uint8_t path[BOT_MAX_PATH_BYTES]; + uint8_t path_len = 0; + bool have_path = false; + if (command.args_len > 0) { + have_path = botParseTraceHexPath(command, flags, path, &path_len); + } else if (message.path && message.path_hash_count > 0) { + uint8_t raw_len = (uint8_t)(message.path_hash_count * hash_size); + if (botTracePathShapeValid(raw_len, flags)) { + botCopyReversedPath(path, message.path, message.path_hash_count, hash_size); + path_len = raw_len; + have_path = true; + } + } + + if (!have_path) return false; + + BotFingerprint request_fingerprint = FirmwareBot::fingerprintFor(message); + char response[BOT_MAX_RESPONSE_LEN + 1]; + size_t response_len = botFormatTraceSent(response, sizeof(response)); + BotFingerprint response_fingerprint = FirmwareBot::responseFingerprintFor(message, response, response_len); + BotFingerprint fingerprint; + uint32_t due_at_millis = 0; + uint32_t now = _ms->getMillis(); + uint32_t bot_identity_seed; + memcpy(&bot_identity_seed, self_id.pub_key, sizeof(bot_identity_seed)); + uint32_t jitter_seed = request_fingerprint.value ? (uint32_t)request_fingerprint.value : 1; + uint8_t queue_depth = (uint8_t)_mgr->getOutboundTotal(); + BotCoordinatorScheduleResult schedule = ResponseCoordinator::schedule(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS, + message, BOT_COMMAND_TRACE, request_fingerprint, + response_fingerprint, now, jitter_seed, + bot_identity_seed, 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 true; + } + + uint32_t tag = getRTCClock()->getCurrentTimeUnique(); + uint32_t auth_code = 0; + getRNG()->random((uint8_t *)&auth_code, sizeof(auth_code)); + if (auth_code == 0) auth_code = tag ^ 0x54435245UL; + + bot_stats.eligible_messages++; + bot_stats.pending_responses++; + FirmwareBot::recordCommandCooldown(bot_command_cooldowns, BOT_COMMAND_COOLDOWN_SLOTS, BOT_COMMAND_TRACE, now, + BOT_TRACE_COOLDOWN_MILLIS); + if (!enqueueBotTrace(message, direct_recipient, channel_idx, path, path_len, flags, fingerprint, response_fingerprint, tag, + auth_code)) { + ResponseCoordinator::cancel(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS, fingerprint); + bot_stats.send_failures++; + } + return true; +} + +bool MyMesh::enqueueBotTrace(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, + const uint8_t *path, uint8_t path_len, uint8_t flags, + BotFingerprint request_fingerprint, BotFingerprint response_fingerprint, uint32_t tag, + uint32_t auth_code) { + if (!path || !botTracePathShapeValid(path_len, flags)) return false; + + size_t slot = BOT_PENDING_TRACE_SLOTS; + for (size_t i = 0; i < BOT_PENDING_TRACE_SLOTS; i++) { + if (pending_bot_traces[i].active && pending_bot_traces[i].request_fingerprint.value == request_fingerprint.value) { + slot = i; + break; + } + if (slot == BOT_PENDING_TRACE_SLOTS && !pending_bot_traces[i].active) slot = i; + } + if (slot == BOT_PENDING_TRACE_SLOTS) return false; + + PendingBotTrace *pending = &pending_bot_traces[slot]; + memset(pending, 0, sizeof(*pending)); + 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)); + } + pending->channel_idx = channel_idx; + pending->request_fingerprint = request_fingerprint; + pending->response_fingerprint = response_fingerprint; + pending->tag = tag; + pending->auth_code = auth_code; + pending->flags = flags; + pending->path_len = path_len; + memcpy(pending->path, path, path_len); + pending->active = true; + return true; +} + +bool MyMesh::sendBotTraceText(const PendingBotTrace &pending, const char *text, size_t text_len, + BotFingerprint response_fingerprint, uint32_t now_millis) { + 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(); + char response[BOT_MAX_RESPONSE_LEN + 1]; + size_t copy_len = text_len; + if (copy_len > BOT_MAX_RESPONSE_LEN) copy_len = BOT_MAX_RESPONSE_LEN; + if (copy_len > 0) memcpy(response, text, copy_len); + response[copy_len] = 0; + int result = sendMessage(*recipient, timestamp, 0, response, 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, text, text_len); + } + + if (success) { + bot_stats.sent_messages++; + ResponseCoordinator::recordRecent(bot_coordinator_recent, BOT_COORDINATOR_RECENT_SLOTS, response_fingerprint, + now_millis); + } else { + bot_stats.send_failures++; + } + return success; +} + +bool MyMesh::sendPendingBotTrace(PendingBotTrace &pending, uint32_t now_millis) { + mesh::Packet *pkt = createTrace(pending.tag, pending.auth_code, pending.flags); + if (!pkt) return false; + + sendDirect(pkt, pending.path, pending.path_len); + pending.sent = true; + pending.expires_at_millis = now_millis + BOT_TRACE_TIMEOUT_MILLIS; + + char response[BOT_MAX_RESPONSE_LEN + 1]; + size_t response_len = botFormatTraceSent(response, sizeof(response)); + return sendBotTraceText(pending, response, response_len, pending.response_fingerprint, now_millis); +} + +void MyMesh::expirePendingBotTraces(uint32_t now_millis) { + for (size_t i = 0; i < BOT_PENDING_TRACE_SLOTS; i++) { + PendingBotTrace *pending = &pending_bot_traces[i]; + if (!pending->active || !pending->sent || (int32_t)(now_millis - pending->expires_at_millis) < 0) continue; + + const char *response = "Trace timed out"; + BotFingerprint response_fingerprint; + response_fingerprint.value = pending->response_fingerprint.value ^ 0x74696d656f7574ULL; + sendBotTraceText(*pending, response, botBoundedStrLen(response, BOT_MAX_RESPONSE_LEN + 1), response_fingerprint, + now_millis); + pending->active = false; + } +} + +bool MyMesh::enqueueEmergencyForward(const BotMessage &message) { + BotEmergencyForward forward; + if (!EmergencyForwarder::format(message, forward)) return false; + + uint8_t free_slots = 0; + for (size_t i = 0; i < BOT_PENDING_EMERGENCY_SLOTS; i++) { + if (!pending_emergency_forwards[i].active) free_slots++; + } + if (free_slots < forward.part_count) return false; + + uint8_t part_idx = 0; + for (size_t i = 0; i < BOT_PENDING_EMERGENCY_SLOTS && part_idx < forward.part_count; i++) { + PendingEmergencyForward *pending = &pending_emergency_forwards[i]; + if (!pending->active) { + pending->text_len = forward.part_lens[part_idx]; + if (pending->text_len > BOT_MAX_GROUP_RESPONSE_LEN) pending->text_len = BOT_MAX_GROUP_RESPONSE_LEN; + if (pending->text_len > 0) memcpy(pending->text, forward.parts[part_idx], pending->text_len); + pending->text[pending->text_len] = 0; + pending->active = true; + part_idx++; + } + } + + return true; +} + +bool MyMesh::observeKnownBotResponse(const BotMessage &message, bool authoritative_sender) { + if (!authoritative_sender || message.channel_kind != BOT_CHANNEL_DM) return false; + if (!KnownBotRegistry::canSuppressNormal(known_bot_entries, BOT_KNOWN_BOT_SLOTS, message.sender_key_prefix, + message.sender_key_prefix_len)) return false; + + bot_stats.known_bot_messages++; + BotFingerprint fingerprint = FirmwareBot::responseFingerprintFor(message, message.text, message.text_len); + if (ResponseCoordinator::recentlySent(bot_coordinator_recent, BOT_COORDINATOR_RECENT_SLOTS, fingerprint, _ms->getMillis())) { + return true; + } + if (ResponseCoordinator::suppress(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS, fingerprint)) { + 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) { + bot_stats.ignored_messages++; + return; + } + if (decision == BOT_POLICY_EMERGENCY_FORWARD) { + bot_stats.emergency_messages++; + if (!enqueueEmergencyForward(message)) bot_stats.emergency_forward_failures++; + return; + } + + if (!bot_prefs.enabled) { + bot_stats.ignored_messages++; + return; + } + + if (observeKnownBotResponse(message, direct_recipient != NULL)) return; + sendQueuedBotResponses(); + + BotCommand command; + if (!FirmwareBot::parseCommand(message.text, message.text_len, &command, + BotPolicy::isPrefixlessCommandAllowed(message.channel_kind))) { + if (message.text_len > 0 && (message.text[0] == '!' || message.text[0] == '/')) bot_stats.parse_errors++; + return; + } + + if ((command.id != BOT_COMMAND_UNKNOWN && command.id != BOT_COMMAND_UNSUPPORTED && + !BotPrefsCodec::commandEnabled(bot_prefs, command.id)) || + FirmwareBot::isCommandOnCooldown(bot_command_cooldowns, BOT_COMMAND_COOLDOWN_SLOTS, command.id, _ms->getMillis())) { + bot_stats.ignored_messages++; + return; + } + + char response[BOT_MAX_RESPONSE_LEN + 1]; + BotCommandResult result; + if (command.id == BOT_COMMAND_TRACE && handleBotTraceCommand(message, direct_recipient, channel_idx, command)) return; + + BotCommandContext context; + buildBotCommandContext(context, command.id); + if (command.id == BOT_COMMAND_PATH || command.id == BOT_COMMAND_TRACER) { + context.path_len = message.path_len; + context.path_hash_size = message.path_hash_size; + context.path_hash_count = message.path_hash_count; + context.path_snr_quarters = message.packet_snr_quarters; + context.path = message.path; + } + result = command.id == BOT_COMMAND_PREFIX ? executeBotPrefixCommand(command, response, sizeof(response)) + : 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; + } + + BotFingerprint request_fingerprint = FirmwareBot::fingerprintFor(message); + BotFingerprint response_fingerprint = FirmwareBot::responseFingerprintFor(message, response, result.text_len); + BotFingerprint fingerprint; + uint32_t due_at_millis = 0; + uint32_t bot_identity_seed; + memcpy(&bot_identity_seed, self_id.pub_key, sizeof(bot_identity_seed)); + uint8_t queue_depth = (uint8_t)_mgr->getOutboundTotal(); + BotCoordinatorScheduleResult schedule = ResponseCoordinator::schedule(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS, + message, command.id, request_fingerprint, + response_fingerprint, _ms->getMillis(), + context.random_seed, bot_identity_seed, + 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; + } + + bot_stats.eligible_messages++; + bot_stats.pending_responses++; + 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, fingerprint, response_fingerprint)) { + ResponseCoordinator::cancel(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS, fingerprint); + bot_stats.send_failures++; + } +} + +void MyMesh::sendQueuedBotResponses() { + uint32_t now = _ms->getMillis(); + while (true) { + BotCoordinatorReady ready = ResponseCoordinator::poll(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS, now); + if (ready.result == BOT_COORDINATOR_READY_NONE) return; + + if (ready.result == BOT_COORDINATOR_READY_SUPPRESSED) { + bot_stats.suppressed_responses++; + } else if (ready.result == BOT_COORDINATOR_READY_EXPIRED) { + bot_stats.expired_responses++; + } + + PendingBotTrace *pending_trace = NULL; + for (size_t i = 0; i < BOT_PENDING_TRACE_SLOTS; i++) { + if (pending_bot_traces[i].active && + pending_bot_traces[i].request_fingerprint.value == ready.request_fingerprint.value) { + pending_trace = &pending_bot_traces[i]; + break; + } + } + if (pending_trace) { + if (ready.result == BOT_COORDINATOR_READY_SEND) { + if (!sendPendingBotTrace(*pending_trace, now)) pending_trace->active = false; + } else { + pending_trace->active = false; + } + continue; + } + + PendingBotResponse *pending = NULL; + for (size_t i = 0; i < BOT_PENDING_RESPONSE_SLOTS; i++) { + if (pending_bot_responses[i].active && pending_bot_responses[i].request_fingerprint.value == ready.request_fingerprint.value) { + pending = &pending_bot_responses[i]; + break; + } + } + if (!pending) continue; + + if (ready.result != BOT_COORDINATOR_READY_SEND) { + pending->active = false; + 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++; + ResponseCoordinator::recordRecent(bot_coordinator_recent, BOT_COORDINATOR_RECENT_SLOTS, + pending->response_fingerprint, now); + } else { + bot_stats.send_failures++; + } + pending->active = false; + } +} + +void MyMesh::sendQueuedEmergencyForwards() { + uint8_t public_channel_idx = 0xFF; + bool have_public = findBotChannel(BOT_CHANNEL_PUBLIC, public_channel_idx); + ChannelDetails public_channel; + if (have_public) have_public = getChannel(public_channel_idx, public_channel); + + for (size_t i = 0; i < BOT_PENDING_EMERGENCY_SLOTS; i++) { + PendingEmergencyForward *pending = &pending_emergency_forwards[i]; + if (!pending->active) continue; + + bool success = false; + if (have_public) { + uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); + success = sendGroupMessage(timestamp, public_channel.channel, _prefs.node_name, pending->text, pending->text_len); + } + + if (success) { + bot_stats.emergency_forwards++; + pending->active = false; + } else { + bot_stats.emergency_forward_failures++; + } + } +} + +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() { + uint32_t now = _ms->getMillis(); + sendQueuedEmergencyForwards(); + if (bot_prefs.enabled) { + sendQueuedBotResponses(); + expirePendingBotTraces(now); + } + if (!bot_prefs.enabled) return; + if (next_bot_local_advert && millisHasNowPassed(next_bot_local_advert)) { + sendBotSelfAdvert(false); + scheduleBotLocalAdvert(bot_prefs.local_advert_interval_ms); + } + if (next_bot_flood_advert && millisHasNowPassed(next_bot_flood_advert)) { + sendBotSelfAdvert(true); + scheduleBotFloodAdvert(bot_prefs.flood_advert_interval_ms); + } +} +#endif + void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp, const char *text) { int i = 0; @@ -566,15 +1652,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_idx, channel_name, text, timestamp, pkt); +#endif } void MyMesh::onChannelDataRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint16_t data_type, @@ -829,6 +1917,23 @@ void MyMesh::onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, } else { MESH_DEBUG_PRINTLN("onTraceRecv(), data received while app offline"); } + +#if CMESH_BOT_ENABLED + uint32_t now = _ms->getMillis(); + for (size_t idx = 0; idx < BOT_PENDING_TRACE_SLOTS; idx++) { + PendingBotTrace *pending = &pending_bot_traces[idx]; + if (!pending->active || !pending->sent || pending->tag != tag || pending->auth_code != auth_code) continue; + + char response[BOT_MAX_RESPONSE_LEN + 1]; + size_t response_len = botFormatTraceResult(response, sizeof(response), tag, flags, path_hashes, path_len, + (int8_t)(packet->getSNR() * 4)); + BotFingerprint response_fingerprint; + response_fingerprint.value = pending->response_fingerprint.value ^ 0x726573756c74ULL; + sendBotTraceText(*pending, response, response_len, response_fingerprint, now); + pending->active = false; + break; + } +#endif } uint32_t MyMesh::calcFloodTimeoutMillisFor(uint32_t pkt_airtime_millis) const { @@ -856,6 +1961,19 @@ 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 + BotPrefsCodec::defaults(bot_prefs); + memset(&bot_stats, 0, sizeof(bot_stats)); + memset(pending_bot_responses, 0, sizeof(pending_bot_responses)); + memset(pending_bot_traces, 0, sizeof(pending_bot_traces)); + memset(pending_emergency_forwards, 0, sizeof(pending_emergency_forwards)); + memset(bot_command_cooldowns, 0, sizeof(bot_command_cooldowns)); + ResponseCoordinator::clear(bot_coordinator_pending, BOT_COORDINATOR_PENDING_SLOTS); + ResponseCoordinator::clearRecent(bot_coordinator_recent, BOT_COORDINATOR_RECENT_SLOTS); + KnownBotRegistry::clear(known_bot_entries, BOT_KNOWN_BOT_SLOTS); + next_bot_local_advert = 0; + next_bot_flood_advert = 0; +#endif // defaults memset(&_prefs, 0, sizeof(_prefs)); @@ -868,6 +1986,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 @@ -956,6 +2077,11 @@ 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 + if (!_store->loadBotPrefs(bot_prefs)) bot_prefs.prefs_load_failures++; + BotPrefsCodec::validate(bot_prefs); + applyBotPrefs(); +#endif } const char *MyMesh::getNodeName() { @@ -1975,7 +3101,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]); @@ -2172,6 +3304,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..8d848fef 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -4,6 +4,15 @@ #include #include "AbstractUITask.h" +#ifndef CMESH_BOT_ENABLED +#define CMESH_BOT_ENABLED 0 +#endif +#if CMESH_BOT_ENABLED +#include "BotTypes.h" +#define BOT_PENDING_RESPONSE_SLOTS BOT_COORDINATOR_PENDING_SLOTS +#define BOT_COMMAND_COOLDOWN_SLOTS 9 +#endif + /*------------ Frame Protocol --------------*/ #define FIRMWARE_VER_CODE 11 @@ -192,6 +201,43 @@ private: return _store->putBlobByKey(key, key_len, src_buf, len); } +#if CMESH_BOT_ENABLED + struct PendingBotTrace; + + 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, + uint32_t sender_timestamp, const mesh::Packet *packet); + void recordBotObservation(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx); + bool observeKnownBotResponse(const BotMessage &message, bool authoritative_sender); + void buildBotCommandContext(BotCommandContext &context, BotCommandId command_id); + bool enqueueBotResponse(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, + const char *text, size_t text_len, BotFingerprint request_fingerprint, + BotFingerprint response_fingerprint); + bool handleBotTraceCommand(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, + const BotCommand &command); + BotCommandResult executeBotPrefixCommand(const BotCommand &command, char *output, size_t output_len); + bool enqueueBotTrace(const BotMessage &message, const ContactInfo *direct_recipient, uint8_t channel_idx, + const uint8_t *path, uint8_t path_len, uint8_t flags, BotFingerprint request_fingerprint, + BotFingerprint response_fingerprint, uint32_t tag, uint32_t auth_code); + bool sendBotTraceText(const PendingBotTrace &pending, const char *text, size_t text_len, + BotFingerprint response_fingerprint, uint32_t now_millis); + bool sendPendingBotTrace(PendingBotTrace &pending, uint32_t now_millis); + void expirePendingBotTraces(uint32_t now_millis); + bool enqueueEmergencyForward(const BotMessage &message); + bool findBotChannel(BotChannelKind kind, uint8_t &channel_idx); + void sendQueuedBotResponses(); + void sendQueuedEmergencyForwards(); + 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 +269,53 @@ 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; + BotFingerprint request_fingerprint; + BotFingerprint response_fingerprint; + char text[BOT_MAX_RESPONSE_LEN + 1]; + size_t text_len; + }; + + struct PendingEmergencyForward { + bool active; + char text[BOT_MAX_GROUP_RESPONSE_LEN + 1]; + size_t text_len; + }; + + struct PendingBotTrace { + bool active; + bool direct; + bool sent; + uint8_t recipient_pub_key[PUB_KEY_SIZE]; + uint8_t channel_idx; + BotFingerprint request_fingerprint; + BotFingerprint response_fingerprint; + uint32_t tag; + uint32_t auth_code; + uint32_t expires_at_millis; + uint8_t flags; + uint8_t path_len; + uint8_t path[MAX_PATH_SIZE]; + }; + + BotPrefs bot_prefs; + BotStats bot_stats; + PendingBotResponse pending_bot_responses[BOT_PENDING_RESPONSE_SLOTS]; + PendingBotTrace pending_bot_traces[BOT_PENDING_TRACE_SLOTS]; + PendingEmergencyForward pending_emergency_forwards[BOT_PENDING_EMERGENCY_SLOTS]; + BotCommandCooldown bot_command_cooldowns[BOT_COMMAND_COOLDOWN_SLOTS]; + BotCoordinatorPending bot_coordinator_pending[BOT_COORDINATOR_PENDING_SLOTS]; + BotCoordinatorRecent bot_coordinator_recent[BOT_COORDINATOR_RECENT_SLOTS]; + BotKnownBotEntry known_bot_entries[BOT_KNOWN_BOT_SLOTS]; + 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/examples/companion_radio/ResponseCoordinator.cpp b/examples/companion_radio/ResponseCoordinator.cpp new file mode 100644 index 00000000..25c03f18 --- /dev/null +++ b/examples/companion_radio/ResponseCoordinator.cpp @@ -0,0 +1,210 @@ +#include "ResponseCoordinator.h" + +#include "BotPolicy.h" + +#include + +namespace { + +bool isNormalChannel(BotChannelKind kind) { + return BotPolicy::isNormalAllowed(kind); +} + +bool sameFingerprint(BotFingerprint a, BotFingerprint b) { + return a.value == b.value; +} + +uint32_t channelDelayBias(BotChannelKind kind) { + if (kind == BOT_CHANNEL_DM) return 0; + if (kind == BOT_CHANNEL_BOT) return 200; + if (kind == BOT_CHANNEL_TESTING) return 400; + return 800; +} + +uint32_t commandDelayBias(BotCommandId command_id) { + if (command_id == BOT_COMMAND_PING || command_id == BOT_COMMAND_TEST) return 0; + if (command_id == BOT_COMMAND_ROLL || command_id == BOT_COMMAND_DICE) return 200; + if (command_id == BOT_COMMAND_STATUS || command_id == BOT_COMMAND_CHANNELS) return 400; + if (command_id == BOT_COMMAND_TRACE || command_id == BOT_COMMAND_TRACER) return 800; + return 100; +} + +uint32_t tieBreakBias(BotFingerprint request_fingerprint, uint32_t bot_identity_seed) { + uint32_t mixed = (uint32_t)request_fingerprint.value ^ (uint32_t)(request_fingerprint.value >> 32) ^ bot_identity_seed; + mixed ^= mixed >> 16; + mixed *= 0x7feb352dUL; + mixed ^= mixed >> 15; + return mixed % 900UL; +} + +uint32_t queueDelayBias(uint8_t queue_depth) { + return (uint32_t)queue_depth * 150UL; +} + +bool millisDue(uint32_t now_millis, uint32_t then_millis) { + return (int32_t)(now_millis - then_millis) >= 0; +} + +} + +namespace ResponseCoordinator { + +void clear(BotCoordinatorPending pending[], size_t pending_count) { + if (!pending) return; + memset(pending, 0, sizeof(BotCoordinatorPending) * pending_count); +} + +void clearRecent(BotCoordinatorRecent recent[], size_t recent_count) { + if (!recent) return; + memset(recent, 0, sizeof(BotCoordinatorRecent) * 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) { + 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; +} + +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) { + 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, base_delay_millis, jitter_millis); + size_t slot = pending_count; + + for (size_t i = 0; i < pending_count; i++) { + if (pending[i].active && sameFingerprint(pending[i].request_fingerprint, request_fingerprint)) { + slot = i; + break; + } + if (slot == pending_count && !pending[i].active) slot = i; + } + if (slot == pending_count) return BOT_COORDINATOR_NO_SPACE; + + bool replaced = pending[slot].active; + pending[slot].active = true; + pending[slot].suppressed = false; + pending[slot].request_fingerprint = request_fingerprint; + pending[slot].response_fingerprint = response_fingerprint; + pending[slot].due_at_millis = due; + pending[slot].expires_at_millis = now_millis + BOT_RESPONSE_PENDING_TTL_MILLIS; + if (fingerprint) *fingerprint = request_fingerprint; + if (due_at_millis) *due_at_millis = due; + return replaced ? BOT_COORDINATOR_REPLACED : BOT_COORDINATOR_SCHEDULED; +} + +bool suppress(BotCoordinatorPending pending[], size_t pending_count, BotFingerprint response_fingerprint) { + if (!pending || response_fingerprint.value == 0) return false; + for (size_t i = 0; i < pending_count; i++) { + if (pending[i].active && sameFingerprint(pending[i].response_fingerprint, response_fingerprint)) { + pending[i].suppressed = true; + return true; + } + } + return false; +} + +bool cancel(BotCoordinatorPending pending[], size_t pending_count, BotFingerprint request_fingerprint) { + if (!pending || request_fingerprint.value == 0) return false; + for (size_t i = 0; i < pending_count; i++) { + if (pending[i].active && sameFingerprint(pending[i].request_fingerprint, request_fingerprint)) { + pending[i].active = false; + return true; + } + } + return false; +} + +BotCoordinatorReady poll(BotCoordinatorPending pending[], size_t pending_count, uint32_t now_millis) { + BotCoordinatorReady ready; + ready.result = BOT_COORDINATOR_READY_NONE; + ready.request_fingerprint.value = 0; + ready.response_fingerprint.value = 0; + if (!pending) return ready; + + for (size_t i = 0; i < pending_count; i++) { + if (!pending[i].active) continue; + if (pending[i].suppressed) { + ready.result = BOT_COORDINATOR_READY_SUPPRESSED; + ready.request_fingerprint = pending[i].request_fingerprint; + ready.response_fingerprint = pending[i].response_fingerprint; + pending[i].active = false; + return ready; + } + if (millisDue(now_millis, pending[i].expires_at_millis)) { + ready.result = BOT_COORDINATOR_READY_EXPIRED; + ready.request_fingerprint = pending[i].request_fingerprint; + ready.response_fingerprint = pending[i].response_fingerprint; + pending[i].active = false; + return ready; + } + if (millisDue(now_millis, pending[i].due_at_millis)) { + ready.result = BOT_COORDINATOR_READY_SEND; + ready.request_fingerprint = pending[i].request_fingerprint; + ready.response_fingerprint = pending[i].response_fingerprint; + pending[i].active = false; + return ready; + } + } + + return ready; +} + +void recordRecent(BotCoordinatorRecent recent[], size_t recent_count, BotFingerprint response_fingerprint, + uint32_t now_millis) { + if (!recent || recent_count == 0 || response_fingerprint.value == 0) return; + + size_t slot = recent_count; + for (size_t i = 0; i < recent_count; i++) { + if (recent[i].active && sameFingerprint(recent[i].response_fingerprint, response_fingerprint)) { + slot = i; + break; + } + if (slot == recent_count && (!recent[i].active || millisDue(now_millis, recent[i].expires_at_millis))) slot = i; + } + if (slot == recent_count) slot = 0; + recent[slot].active = true; + recent[slot].response_fingerprint = response_fingerprint; + recent[slot].expires_at_millis = now_millis + BOT_RESPONSE_RECENT_TTL_MILLIS; +} + +bool recentlySent(BotCoordinatorRecent recent[], size_t recent_count, BotFingerprint response_fingerprint, + uint32_t now_millis) { + if (!recent || response_fingerprint.value == 0) return false; + for (size_t i = 0; i < recent_count; i++) { + if (!recent[i].active) continue; + if (millisDue(now_millis, recent[i].expires_at_millis)) { + recent[i].active = false; + continue; + } + if (sameFingerprint(recent[i].response_fingerprint, response_fingerprint)) return true; + } + return false; +} + +} diff --git a/examples/companion_radio/ResponseCoordinator.h b/examples/companion_radio/ResponseCoordinator.h new file mode 100644 index 00000000..7717bee7 --- /dev/null +++ b/examples/companion_radio/ResponseCoordinator.h @@ -0,0 +1,33 @@ +#pragma once + +#include "BotTypes.h" + +namespace ResponseCoordinator { + +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); +void recordRecent(BotCoordinatorRecent recent[], size_t recent_count, BotFingerprint response_fingerprint, + uint32_t now_millis); +bool recentlySent(BotCoordinatorRecent recent[], size_t recent_count, BotFingerprint response_fingerprint, + uint32_t now_millis); + +} diff --git a/platformio.ini b/platformio.ini index 864e5e1f..dfed7dbd 100644 --- a/platformio.ini +++ b/platformio.ini @@ -54,6 +54,14 @@ build_src_filter = ; ----------------- ESP32 --------------------- +[cmesh_bot_production] +build_flags = + -D CMESH_BOT_ENABLED=1 + -UENABLE_PRIVATE_KEY_IMPORT + -UENABLE_PRIVATE_KEY_EXPORT + -D ENABLE_PRIVATE_KEY_IMPORT=0 + -D ENABLE_PRIVATE_KEY_EXPORT=0 + [esp32_base] extends = arduino_base platform = platformio/espressif32@6.11.0 diff --git a/variants/heltec_v3/platformio.ini b/variants/heltec_v3/platformio.ini index 803ee683..ca9cf00d 100644 --- a/variants/heltec_v3/platformio.ini +++ b/variants/heltec_v3/platformio.ini @@ -144,6 +144,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=SSD1306Display + ${cmesh_bot_production.build_flags} ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${Heltec_lora32_v3.build_src_filter} @@ -163,6 +164,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=SSD1306Display + ${cmesh_bot_production.build_flags} -D BLE_PIN_CODE=123456 ; dynamic, random PIN -D AUTO_SHUTDOWN_MILLIVOLTS=3400 -D BLE_DEBUG_LOGGING=1 diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index ea7e49c3..d6610098 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -122,6 +122,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + ${cmesh_bot_production.build_flags} ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${rak4631.build_src_filter} @@ -143,6 +144,7 @@ build_flags = -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 + ${cmesh_bot_production.build_flags} -D BLE_PIN_CODE=123456 -D BLE_DEBUG_LOGGING=1 -D OFFLINE_QUEUE_SIZE=256