From f248109159df2f41f586aea4e7e63aaa348445ce Mon Sep 17 00:00:00 2001 From: cj-vana Date: Thu, 14 May 2026 13:43:02 -0600 Subject: [PATCH] =?UTF-8?q?forge:=20step=203=20=E2=80=94=20add=20firmware?= =?UTF-8?q?=20bot=20core=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...0001-Add-companion-firmware-bot-core.patch | 423 ++++++++++++++++++ tests/firmware_bot/run_tests.py | 33 ++ tests/firmware_bot/test_firmware_bot.cpp | 133 ++++++ 3 files changed, 589 insertions(+) create mode 100644 patches/meshcore/0001-Add-companion-firmware-bot-core.patch create mode 100644 tests/firmware_bot/run_tests.py create mode 100644 tests/firmware_bot/test_firmware_bot.cpp diff --git a/patches/meshcore/0001-Add-companion-firmware-bot-core.patch b/patches/meshcore/0001-Add-companion-firmware-bot-core.patch new file mode 100644 index 0000000..fd05928 --- /dev/null +++ b/patches/meshcore/0001-Add-companion-firmware-bot-core.patch @@ -0,0 +1,423 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: cj-vana +Date: Thu, 14 May 2026 13:36:50 -0600 +Subject: [PATCH] Add companion firmware bot core + +--- + examples/companion_radio/BotPolicy.cpp | 58 +++++++ + examples/companion_radio/BotPolicy.h | 12 ++ + examples/companion_radio/BotTypes.h | 99 ++++++++++++ + examples/companion_radio/FirmwareBot.cpp | 193 +++++++++++++++++++++++ + examples/companion_radio/FirmwareBot.h | 13 ++ + 5 files changed, 375 insertions(+) + create mode 100644 examples/companion_radio/BotPolicy.cpp + create mode 100644 examples/companion_radio/BotPolicy.h + create mode 100644 examples/companion_radio/BotTypes.h + create mode 100644 examples/companion_radio/FirmwareBot.cpp + create mode 100644 examples/companion_radio/FirmwareBot.h + +diff --git a/examples/companion_radio/BotPolicy.cpp b/examples/companion_radio/BotPolicy.cpp +new file mode 100644 +index 00000000..bd3fc6c7 +--- /dev/null ++++ b/examples/companion_radio/BotPolicy.cpp +@@ -0,0 +1,58 @@ ++#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; ++} ++ ++} ++ ++namespace BotPolicy { ++ ++BotChannelKind classifyChannel(const char* name, size_t len, bool direct_message) { ++ if (direct_message) return BOT_CHANNEL_DM; ++ if (equalsIgnoreCase(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 (equalsIgnoreCase(name, len, "#emergency")) 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; ++} ++ ++} +diff --git a/examples/companion_radio/BotPolicy.h b/examples/companion_radio/BotPolicy.h +new file mode 100644 +index 00000000..e074e3eb +--- /dev/null ++++ b/examples/companion_radio/BotPolicy.h +@@ -0,0 +1,12 @@ ++#pragma once ++ ++#include "BotTypes.h" ++ ++namespace BotPolicy { ++ ++BotChannelKind classifyChannel(const char* name, size_t len, bool direct_message); ++BotPolicyDecision decide(BotChannelKind kind); ++bool isNormalAllowed(BotChannelKind kind); ++bool isEmergency(BotChannelKind kind); ++ ++} +diff --git a/examples/companion_radio/BotTypes.h b/examples/companion_radio/BotTypes.h +new file mode 100644 +index 00000000..869ae3ae +--- /dev/null ++++ b/examples/companion_radio/BotTypes.h +@@ -0,0 +1,99 @@ ++#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_SENDER_KEY_PREFIX_LEN 6 ++ ++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, ++ BOT_COMMAND_PING, ++ BOT_COMMAND_TEST, ++ BOT_COMMAND_HELLO, ++ BOT_COMMAND_ABOUT, ++ BOT_COMMAND_DICE, ++ BOT_COMMAND_STATUS, ++ BOT_COMMAND_UNKNOWN ++}; ++ ++enum BotWriteResult : uint8_t { ++ BOT_WRITE_OK = 0, ++ BOT_WRITE_TRUNCATED, ++ BOT_WRITE_NO_SPACE ++}; ++ ++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]; ++ uint32_t sender_timestamp; ++ char text[BOT_MAX_TEXT_LEN + 1]; ++ size_t text_len; ++}; ++ ++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 BotPrefs { ++ bool enabled; ++ uint16_t normal_delay_ms; ++ uint16_t normal_jitter_ms; ++ 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]; ++}; ++ ++struct BotStats { ++ uint32_t observed_messages; ++ uint32_t ignored_messages; ++ uint32_t eligible_messages; ++ uint32_t emergency_messages; ++ uint32_t parse_errors; ++}; ++ ++static_assert(sizeof(BotMessage) <= 240, "BotMessage RAM budget exceeded"); ++static_assert(sizeof(BotCommand) <= 120, "BotCommand RAM budget exceeded"); ++static_assert(sizeof(BotResponse) <= 184, "BotResponse RAM budget exceeded"); ++static_assert(sizeof(BotPrefs) <= 128, "BotPrefs RAM budget exceeded"); ++static_assert(sizeof(BotStats) <= 32, "BotStats RAM budget exceeded"); +diff --git a/examples/companion_radio/FirmwareBot.cpp b/examples/companion_radio/FirmwareBot.cpp +new file mode 100644 +index 00000000..c9447282 +--- /dev/null ++++ b/examples/companion_radio/FirmwareBot.cpp +@@ -0,0 +1,193 @@ ++#include "FirmwareBot.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; ++} ++ ++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 namesEqual(const char* name, size_t len, const char* expected) { ++ size_t expected_len = strlen(expected); ++ if (len != expected_len) return false; ++ for (size_t i = 0; i < len; i++) { ++ if (tolower((unsigned char)name[i]) != expected[i]) return false; ++ } ++ return true; ++} ++ ++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) { ++ if (namesEqual(name, len, "help") || namesEqual(name, len, "cmd") || namesEqual(name, len, "commands")) return BOT_COMMAND_HELP; ++ if (namesEqual(name, len, "ping")) return BOT_COMMAND_PING; ++ if (namesEqual(name, len, "test")) return BOT_COMMAND_TEST; ++ if (namesEqual(name, len, "hello") || namesEqual(name, len, "hi")) return BOT_COMMAND_HELLO; ++ if (namesEqual(name, len, "about")) return BOT_COMMAND_ABOUT; ++ if (namesEqual(name, len, "dice") || namesEqual(name, len, "roll")) return BOT_COMMAND_DICE; ++ if (namesEqual(name, len, "status")) return BOT_COMMAND_STATUS; ++ return BOT_COMMAND_UNKNOWN; ++} ++ ++bool parseCommand(const char* text, size_t text_len, BotCommand* command) { ++ 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 < 2 || (normalized[0] != '!' && normalized[0] != '/')) return false; ++ ++ size_t pos = 1; ++ 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; ++ command->id = name_len > BOT_MAX_COMMAND_NAME_LEN ? BOT_COMMAND_UNKNOWN : commandIdForName(command->name, copy_name_len); ++ ++ 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; ++} ++ ++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 = 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--; ++ } ++ hash = fnv1aUpdateTextLower(hash, channel_name, channel_name_len); ++ hash = fnv1aUpdateBytes(hash, message.sender_key_prefix, sizeof(message.sender_key_prefix)); ++ 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; ++} ++ ++} +diff --git a/examples/companion_radio/FirmwareBot.h b/examples/companion_radio/FirmwareBot.h +new file mode 100644 +index 00000000..8fce16ab +--- /dev/null ++++ b/examples/companion_radio/FirmwareBot.h +@@ -0,0 +1,13 @@ ++#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); ++BotWriteResult writeResponse(char* output, size_t output_len, const char* text, size_t text_len, size_t* written); ++BotFingerprint fingerprintFor(const BotMessage& message); ++BotCommandId commandIdForName(const char* name, size_t len); ++ ++} diff --git a/tests/firmware_bot/run_tests.py b/tests/firmware_bot/run_tests.py new file mode 100644 index 0000000..9038bf3 --- /dev/null +++ b/tests/firmware_bot/run_tests.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +import os +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +BUILD_DIR = ROOT / "out" / "tests" / "firmware_bot" +SRC_DIR = ROOT / "vendor" / "MeshCore" / "examples" / "companion_radio" + + +def main(): + BUILD_DIR.mkdir(parents=True, exist_ok=True) + binary = BUILD_DIR / "test_firmware_bot" + command = [ + os.environ.get("CXX", "c++"), + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-I", + str(SRC_DIR), + str(ROOT / "tests" / "firmware_bot" / "test_firmware_bot.cpp"), + str(SRC_DIR / "FirmwareBot.cpp"), + str(SRC_DIR / "BotPolicy.cpp"), + "-o", + str(binary), + ] + subprocess.run(command, check=True) + subprocess.run([str(binary)], check=True) + + +if __name__ == "__main__": + main() diff --git a/tests/firmware_bot/test_firmware_bot.cpp b/tests/firmware_bot/test_firmware_bot.cpp new file mode 100644 index 0000000..5f93f36 --- /dev/null +++ b/tests/firmware_bot/test_firmware_bot.cpp @@ -0,0 +1,133 @@ +#include +#include +#include + +#include "BotPolicy.h" +#include "FirmwareBot.h" + +static void test_channel_policy() { + assert(BotPolicy::classifyChannel(NULL, 0, true) == BOT_CHANNEL_DM); + assert(BotPolicy::decide(BOT_CHANNEL_DM) == BOT_POLICY_ALLOW_NORMAL); + assert(BotPolicy::classifyChannel("Public", 6, false) == BOT_CHANNEL_PUBLIC); + assert(BotPolicy::decide(BOT_CHANNEL_PUBLIC) == BOT_POLICY_IGNORE); + assert(BotPolicy::classifyChannel("#bot", 4, false) == BOT_CHANNEL_BOT); + assert(BotPolicy::decide(BOT_CHANNEL_BOT) == BOT_POLICY_ALLOW_NORMAL); + assert(BotPolicy::classifyChannel("testing", 7, false) == BOT_CHANNEL_TESTING); + assert(BotPolicy::decide(BOT_CHANNEL_TESTING) == BOT_POLICY_ALLOW_NORMAL); + assert(BotPolicy::classifyChannel("#emergency", 10, false) == BOT_CHANNEL_EMERGENCY); + assert(BotPolicy::decide(BOT_CHANNEL_EMERGENCY) == BOT_POLICY_EMERGENCY_FORWARD); + assert(BotPolicy::classifyChannel("#botnet", 7, false) == BOT_CHANNEL_OTHER); + assert(BotPolicy::decide(BOT_CHANNEL_OTHER) == BOT_POLICY_IGNORE); +} + +static void test_normalize_text() { + char out[32]; + size_t written = 0; + const char text[] = " hi\tthere\n\x01mesh "; + assert(FirmwareBot::normalizeText(text, sizeof(text) - 1, out, sizeof(out), &written) == BOT_WRITE_OK); + assert(strcmp(out, "hi there mesh") == 0); + assert(written == strlen(out)); + + const char with_nul[] = { 'h', 'i', 0, '!', 0 }; + assert(FirmwareBot::normalizeText(with_nul, sizeof(with_nul), out, sizeof(out), &written) == BOT_WRITE_OK); + assert(strcmp(out, "hi") == 0); + + const char utf8[] = "!hello café"; + assert(FirmwareBot::normalizeText(utf8, sizeof(utf8) - 1, out, sizeof(out), &written) == BOT_WRITE_OK); + assert(strcmp(out, "!hello café") == 0); +} + +static void test_normalize_truncation() { + char out[6]; + size_t written = 0; + assert(FirmwareBot::normalizeText("abcdef", 6, out, sizeof(out), &written) == BOT_WRITE_TRUNCATED); + assert(strcmp(out, "abcde") == 0); + assert(written == 5); + assert(FirmwareBot::normalizeText("abc", 3, NULL, 0, &written) == BOT_WRITE_NO_SPACE); + + char max_input[BOT_MAX_TEXT_LEN + 8]; + for (size_t i = 0; i < sizeof(max_input); i++) max_input[i] = 'x'; + char max_output[BOT_MAX_TEXT_LEN + 1]; + assert(FirmwareBot::normalizeText(max_input, BOT_MAX_TEXT_LEN, max_output, sizeof(max_output), &written) == BOT_WRITE_OK); + assert(written == BOT_MAX_TEXT_LEN); + assert(max_output[BOT_MAX_TEXT_LEN] == 0); + assert(FirmwareBot::normalizeText(max_input, BOT_MAX_TEXT_LEN + 1, max_output, sizeof(max_output), &written) == BOT_WRITE_TRUNCATED); + assert(written == BOT_MAX_TEXT_LEN); +} + +static void test_parse_command() { + BotCommand command; + assert(!FirmwareBot::parseCommand("hello bot", 9, &command)); + assert(FirmwareBot::parseCommand("!PING", 5, &command)); + assert(command.id == BOT_COMMAND_PING); + assert(strcmp(command.name, "ping") == 0); + assert(command.args_len == 0); + + assert(FirmwareBot::parseCommand("/roll: 2d6", 10, &command)); + assert(command.id == BOT_COMMAND_DICE); + assert(strcmp(command.name, "roll") == 0); + assert(strcmp(command.args, "2d6") == 0); + + assert(FirmwareBot::parseCommand("!help, please", 13, &command)); + assert(command.id == BOT_COMMAND_HELP); + assert(strcmp(command.args, "please") == 0); + + assert(FirmwareBot::parseCommand("!wat", 4, &command)); + assert(command.id == BOT_COMMAND_UNKNOWN); + + assert(!FirmwareBot::parseCommand("!", 1, &command)); +} + +static void test_response_write() { + char out[8]; + size_t written = 0; + assert(FirmwareBot::writeResponse(out, sizeof(out), "pong", 4, &written) == BOT_WRITE_OK); + assert(strcmp(out, "pong") == 0); + assert(written == 4); + + assert(FirmwareBot::writeResponse(out, sizeof(out), "123456789", 9, &written) == BOT_WRITE_TRUNCATED); + assert(strcmp(out, "1234567") == 0); + assert(written == 7); + + assert(FirmwareBot::writeResponse(NULL, 0, "x", 1, &written) == BOT_WRITE_NO_SPACE); + assert(FirmwareBot::writeResponse(out, sizeof(out), NULL, 1, &written) == BOT_WRITE_NO_SPACE); + assert(out[0] == 0); +} + +static BotMessage make_message(const char* channel, const char* text) { + BotMessage message; + memset(&message, 0, sizeof(message)); + message.channel_kind = BotPolicy::classifyChannel(channel, strlen(channel), false); + strncpy(message.channel_name, channel, sizeof(message.channel_name) - 1); + strncpy(message.sender_name, "alice", sizeof(message.sender_name) - 1); + for (size_t i = 0; i < sizeof(message.sender_key_prefix); i++) message.sender_key_prefix[i] = (uint8_t)(i + 1); + message.sender_timestamp = 12345; + strncpy(message.text, text, sizeof(message.text) - 1); + message.text_len = strlen(message.text); + return message; +} + +static void test_fingerprint() { + BotMessage a = make_message("#bot", "!PING"); + BotMessage b = make_message("bot", "!ping"); + BotMessage c = make_message("#testing", "!ping"); + BotFingerprint fa = FirmwareBot::fingerprintFor(a); + BotFingerprint fb = FirmwareBot::fingerprintFor(b); + BotFingerprint fc = FirmwareBot::fingerprintFor(c); + assert(fa.value == fb.value); + assert(fa.value != fc.value); + + b.sender_key_prefix[0] = 99; + assert(fa.value != FirmwareBot::fingerprintFor(b).value); +} + +int main() { + test_channel_policy(); + test_normalize_text(); + test_normalize_truncation(); + test_parse_command(); + test_response_write(); + test_fingerprint(); + printf("firmware_bot tests passed\n"); + return 0; +}