diff --git a/examples/companion_radio_bot/BOT_README.md b/examples/companion_radio_bot/BOT_README.md new file mode 100644 index 000000000..a69235464 --- /dev/null +++ b/examples/companion_radio_bot/BOT_README.md @@ -0,0 +1,163 @@ +# Companion Radio + Built-in Debug Bot + +This is the standard MeshCore **BLE Companion** firmware with a built-in +"debug bot". When the node receives a **direct text message** over the +LoRa mesh whose text begins with `!`, it auto-replies over the mesh with useful +debugging info — while still behaving as a normal companion (the incoming +message is still delivered to the phone app over BLE). + +Supported boards (PlatformIO env names): + +| Board | Env | +|-------|-----| +| Seeed XIAO ESP32-S3 + Wio-SX1262 | `Xiao_S3_WIO_companion_radio_ble_bot` | +| Seeed XIAO nRF52840 + Wio-SX1262 | `Xiao_nrf52_companion_radio_ble_bot` | +| Heltec LoRa32 V3 | `Heltec_v3_companion_radio_ble_bot` | +| Heltec LoRa32 V4 | `heltec_v4_companion_radio_ble_bot` | +| RAK4631 (WisBlock) | `RAK_4631_companion_radio_ble_bot` | +| RAK WisMesh 1W Booster (RAK3401 + RAK13302) | `RAK_3401_companion_radio_ble_bot` | +| Seeed T1000-E Card Tracker | `t1000e_companion_radio_ble_bot` | +| Heltec T114 | `Heltec_t114_companion_radio_ble_bot` | + +Adding another board is ~12 lines of `platformio.ini` (see +`variants/bot_envs/platformio.ini`) — all bot code is board-agnostic. + +## Commands + +Send any of these as a normal direct message to the bot node from another +MeshCore client: + +| Command | Reply | +|----------------|-------| +| `!ping` | Reports the requester's signal into us: `: heard you at SNR …, RSSI …, N hop(s)` | +| `!rf` | SNR + RSSI of the received request packet | +| `!path` | Route the request arrived on, with hop hashes resolved to contact names: `flood, N hop(s): AA(RptAlpha) BB(?) …` or `direct route` | +| `!status` | name, freq/SF/BW/CR, TX power, contact count, free heap | +| `!stats` | Packet RX/TX counts (flood/direct) + TX/RX airtime — mesh-health snapshot | +| `!neighbors` | Recently-heard nodes + hop counts (alias `!seen`) | +| `!uptime` | Time since boot (`2d 4h 13m 7s`) | +| `!time` | Node clock (UTC epoch) if the RTC is set | +| `!ver` | Firmware version + build date | + +`!path` and `!rf` describe the **request packet as this node received it**, +which is exactly what you want when probing a path through the mesh. `!path` +resolves each hop's hash against this node's contact list; hops it doesn't +have a contact for show as `(?)`. + +Unrecognised `!` commands are silently ignored (no reply), so typos and other +bots' command prefixes don't generate mesh traffic. + +## Where it listens + +- **Direct messages:** the bot replies to a `!`-command sent to it as a direct + message from any contact (subject to rate limiting). +- **Group channels:** the bot also replies to `!`-commands posted in an + **allow-listed** channel, posting the reply back to the **same channel**. + Channels not on the list (e.g. `Public`) are ignored, so the bot can't spam + busy channels. The allow-list is **empty by default** (DM-only) and is + configured at runtime — see below. Replies never start with `!`, so they + cannot re-trigger the bot. + +## Runtime configuration + +The bot is configured **locally** over the companion protocol's custom-vars +frames — from meshcore-cli or any client that exposes custom variables — and +NOT via mesh messages, so only the paired/local user can change it. Settings +persist to flash (`/bot_prefs`) and survive reboots. + +| Var | Values | Meaning | +|-----|--------|---------| +| `bot` | `1` / `0` (also `on`/`off`) | Enable/disable all bot replies | +| `bot.channels` | `#name[,#name...]` or `none` | Channel allow-list (default: `none`, DM-only) | + +With [meshcore-cli](https://github.com/meshcore-dev/meshcore-cli) (unknown +`set`/`get` names fall through to custom vars): + +```sh +meshcore-cli -d "XiaoS3 Bot" set bot 0 # mute the bot +meshcore-cli -d "XiaoS3 Bot" set bot 1 # re-enable it +meshcore-cli -d "XiaoS3 Bot" set bot.channels "#test,#bots" # allow channels +meshcore-cli -d "XiaoS3 Bot" set bot.channels none # DM-only again +meshcore-cli -d "XiaoS3 Bot" get custom # show all vars +``` + +Channel names must match the stored channel name exactly, including the +leading `#`, and the node must have those channels added +(key = `SHA256(name)[:16]`). + +## How it works + +This is a **separate firmware type** that layers on top of the stock companion +example without modifying it. The upstream tree (`examples/companion_radio/`, +`variants//`) is untouched, so pulling from upstream never conflicts +with the bot. Everything lives in `examples/companion_radio_bot/` plus one +env file holding all the board envs (`variants/bot_envs/platformio.ini`): + +- `BotCommands.{h,cpp}` — all the bot logic (commands, rate limiting). +- `BotConfig.{h,cpp}` — the persisted runtime settings (`/bot_prefs` on the + node's filesystem): enabled flag + channel allow-list. +- `BotSensorManager.h` / `BotTarget.cpp` — expose those settings as the + `bot` / `bot.channels` custom vars. `BotTarget.cpp` compiles the board's + stock `target.cpp` with the global `sensors` object swapped for a wrapper + subclass that adds the two settings to the custom-vars get/set hooks. +- `BotMyMesh.cpp` — compiles the stock `MyMesh.cpp` with uses of `sensors` + routed through an opaque reference accessor. Necessary because the + compiler devirtualizes calls on a global object of known type, which + would silently bypass the wrapper's overrides (it did, until this shim). +- `BotMesh.h` — a `MyMesh` subclass; the only coupling point. It overrides the + two virtual receive callbacks (`onMessageRecv`, `onChannelMessageRecv`) to + hand messages to the bot, then delegates to the stock behaviour, and + shadows `begin()` to start the bot's uptime clock. +- `main.cpp` — a shim that `#include`s the stock companion `main.cpp` verbatim + (no copy to keep in sync), with `MyMesh` renamed to `BotMesh` so the global + mesh object is our subclass. Because `MyMesh.h` declares + `extern MyMesh the_mesh;`, the bot instance is named `the_bot_mesh` and the + env links it back under the original symbol with + `-Wl,--defsym,the_mesh=the_bot_mesh` (other translation units like + `UITask.cpp` resolve against it normally). + +The bot uses only `MyMesh`'s **public** API (`sendMessage`, `getNodePrefs`, +`getRecentlyHeard`, `advert`, `getRTCClock`), so it stays decoupled. Stock +companion builds are completely unaffected — they never compile these files. + +### Abuse protection (rate limiting) + +A node receiving a flood of `!` messages must not be tricked into hammering the +airwaves. `BotCommands` enforces three limits (all overridable via `-D`): + +| Define | Default | Meaning | +|--------|---------|---------| +| `BOT_MIN_REPLY_INTERVAL_MS` | `1200` | Min gap between any two bot replies | +| `BOT_SENDER_COOLDOWN_MS` | `5000` | Per-sender cooldown | +| `BOT_MAX_REPLIES_PER_MIN` | `20` | Hard cap on replies per rolling 60 s | + +Dropped requests are silently ignored (no reply), but are still forwarded to the +phone app. + +### Other build options + +| Define | Default | Meaning | +|--------|---------|---------| +| `BOT_CMD_PREFIX` | `'!'` | Command prefix character | +| `BOT_FORWARD_TO_APP` | `1` | `0` = hide bot commands from the phone app | + +## Build & flash + +Use the env for your board from the table at the top, e.g.: + +```sh +# build +pio run -e Xiao_S3_WIO_companion_radio_ble_bot + +# build + flash (board on USB) +pio run -e Xiao_S3_WIO_companion_radio_ble_bot -t upload + +# serial monitor +pio device monitor -b 115200 +``` + +nRF52 boards (RAK4631, T1000-E, T114) also produce a `firmware.uf2` in +`.pio/build//` for drag-and-drop flashing via the UF2 bootloader. + +Default BLE pairing PIN is `123456` (`BLE_PIN_CODE`). Pair with any MeshCore +client (phone app, web client) as you would the normal companion firmware. diff --git a/examples/companion_radio_bot/BotCommands.cpp b/examples/companion_radio_bot/BotCommands.cpp new file mode 100644 index 000000000..7ecc21870 --- /dev/null +++ b/examples/companion_radio_bot/BotCommands.cpp @@ -0,0 +1,310 @@ +#include "BotCommands.h" +#include "BotConfig.h" +#include "MyMesh.h" // full MyMesh definition + target.h (radio_driver, sensors) + +#include +#include + +void BotCommands::reset() { + _boot_millis = 0; + _last_reply_ms = 0; + _window_start_ms = 0; + _window_count = 0; + memset(_senders, 0, sizeof(_senders)); +} + +void BotCommands::begin() { + _boot_millis = millis(); + _window_start_ms = _boot_millis; +} + +// ---- Rate limiting ---------------------------------------------------------- + +bool BotCommands::rateLimitOk(const uint8_t* pub_key, unsigned long now) { + // Rolling 60s global window cap. + if (now - _window_start_ms >= 60000UL) { + _window_start_ms = now; + _window_count = 0; + } + if (_window_count >= BOT_MAX_REPLIES_PER_MIN) return false; + + // Global minimum gap between any two replies. + if (_last_reply_ms != 0 && (now - _last_reply_ms) < BOT_MIN_REPLY_INTERVAL_MS) return false; + + // Per-sender cooldown. Find existing slot, or the oldest/empty slot to reuse. + int slot = -1, oldest = 0; + for (int i = 0; i < BOT_SENDER_TABLE_SIZE; i++) { + if (memcmp(_senders[i].prefix, pub_key, 4) == 0 && + (_senders[i].prefix[0] | _senders[i].prefix[1] | _senders[i].prefix[2] | _senders[i].prefix[3])) { + slot = i; + break; + } + if (_senders[i].last_ms < _senders[oldest].last_ms) oldest = i; + } + if (slot >= 0) { + if ((now - _senders[slot].last_ms) < BOT_SENDER_COOLDOWN_MS) return false; + } else { + slot = oldest; + memcpy(_senders[slot].prefix, pub_key, 4); + } + _senders[slot].last_ms = now; + return true; +} + +// ---- Helpers ---------------------------------------------------------------- + +static void fmtUptime(char* out, size_t out_sz, uint32_t secs) { + uint32_t d = secs / 86400; secs %= 86400; + uint32_t h = secs / 3600; secs %= 3600; + uint32_t m = secs / 60; secs %= 60; + if (d > 0) { + snprintf(out, out_sz, "%lud %luh %lum %lus", (unsigned long)d, (unsigned long)h, + (unsigned long)m, (unsigned long)secs); + } else if (h > 0) { + snprintf(out, out_sz, "%luh %lum %lus", (unsigned long)h, (unsigned long)m, (unsigned long)secs); + } else { + snprintf(out, out_sz, "%lum %lus", (unsigned long)m, (unsigned long)secs); + } +} + +// Resolve a flood-path repeater hash (first hsize bytes of its public key) to a +// contact name. Returns false if no known contact matches. +static bool lookupNameByHash(MyMesh& mesh, const uint8_t* hash, uint8_t hsize, + char* name, size_t name_sz) { + ContactsIterator it; + ContactInfo ci; + while (it.hasNext(&mesh, ci)) { + if (memcmp(ci.id.pub_key, hash, hsize) == 0) { + snprintf(name, name_sz, "%s", ci.name); + return true; + } + } + return false; +} + +// ---- Command dispatch ------------------------------------------------------- + +int BotCommands::buildReply(MyMesh& mesh, mesh::Packet* pkt, const char* requester, + const char* cmd, const char* args, char* out, size_t out_sz) { + NodePrefs* p = mesh.getNodePrefs(); + + if (!strcasecmp(cmd, "ping")) { + // Report the REQUESTER's signal into us - the key range-testing metric. + const char* who = (requester && requester[0]) ? requester : "you"; + return snprintf(out, out_sz, "%s: heard you at SNR %.1fdB, RSSI %ddBm, %u hop(s)", + who, pkt->getSNR(), (int)radio_driver.getLastRSSI(), + (unsigned)(pkt->isRouteFlood() ? pkt->getPathHashCount() : 0)); + } + + if (!strcasecmp(cmd, "uptime")) { + char up[40]; + fmtUptime(up, sizeof(up), (uint32_t)((millis() - _boot_millis) / 1000UL)); + return snprintf(out, out_sz, "up %s", up); + } + + if (!strcasecmp(cmd, "path")) { + if (pkt->isRouteFlood()) { + uint8_t hops = pkt->getPathHashCount(); // actual number of repeater hashes + uint8_t hsize = pkt->getPathHashSize(); // bytes per hash + uint16_t nbytes = pkt->getPathByteLen(); // hops * hsize + if (nbytes > MAX_PATH_SIZE) nbytes = MAX_PATH_SIZE; + int w = snprintf(out, out_sz, "flood, %u hop(s)", (unsigned)hops); + if (hsize > 0 && nbytes >= hsize) { + w += snprintf(out + w, out_sz - w, ":"); + uint16_t shown = 0; + // per hop: " HASH(name)" - name resolved from contacts, "?" if unknown + for (uint16_t h = 0; (uint16_t)((h + 1) * hsize) <= nbytes; h++) { + if ((size_t)w + hsize * 2 + 18 >= out_sz) break; // out of reply space + const uint8_t* hash = pkt->path + h * hsize; + w += snprintf(out + w, out_sz - w, " "); + for (uint8_t b = 0; b < hsize; b++) + w += snprintf(out + w, out_sz - w, "%02X", hash[b]); + char name[14]; + if (lookupNameByHash(mesh, hash, hsize, name, sizeof(name))) + w += snprintf(out + w, out_sz - w, "(%s)", name); + else + w += snprintf(out + w, out_sz - w, "(?)"); + shown++; + } + if (shown * hsize < nbytes) w += snprintf(out + w, out_sz - w, " ..."); + } + return w; + } + return snprintf(out, out_sz, "direct route"); + } + + if (!strcasecmp(cmd, "rf")) { + // Signal quality of the request packet as this node received it. + return snprintf(out, out_sz, "SNR %.2f dB, RSSI %d dBm", + pkt->getSNR(), (int)radio_driver.getLastRSSI()); + } + + if (!strcasecmp(cmd, "status")) { + int w = snprintf(out, out_sz, "%s | %.3fMHz SF%u BW%.0f CR%u | TX%ddBm | contacts:%d", + p->node_name, p->freq, p->sf, p->bw, p->cr, + (int)p->tx_power_dbm, mesh.getNumContacts()); +#ifdef ESP32 + w += snprintf(out + w, out_sz - w, " | heap:%uK", (unsigned)(ESP.getFreeHeap() / 1024)); +#endif + return w; + } + + if (!strcasecmp(cmd, "ver")) { + return snprintf(out, out_sz, "MeshCore companion-bot %s (%s)", FIRMWARE_VERSION, FIRMWARE_BUILD_DATE); + } + + if (!strcasecmp(cmd, "time")) { + uint32_t t = mesh.getRTCClock()->getCurrentTime(); + if (t < 1700000000UL) { // RTC not set (before ~2023) + return snprintf(out, out_sz, "clock not set (epoch %lu)", (unsigned long)t); + } + return snprintf(out, out_sz, "UTC epoch %lu", (unsigned long)t); + } + + if (!strcasecmp(cmd, "neighbors") || !strcasecmp(cmd, "neighbours") || !strcasecmp(cmd, "seen")) { + AdvertPath heard[6]; + int n = mesh.getRecentlyHeard(heard, 6); + int w = snprintf(out, out_sz, "recently heard:"); + int shown = 0; + for (int k = 0; k < n && (size_t)w + 8 < out_sz; k++) { + if (heard[k].name[0] == 0) continue; // empty slot + const char* hops = (heard[k].path_len == 0xFF) ? "?" : NULL; + if (hops) { + w += snprintf(out + w, out_sz - w, " %s(%s)", heard[k].name, hops); + } else { + w += snprintf(out + w, out_sz - w, " %s(%uh)", heard[k].name, (unsigned)heard[k].path_len); + } + shown++; + } + if (shown == 0) w += snprintf(out + w, out_sz - w, " none"); + return w; + } + + if (!strcasecmp(cmd, "stats")) { + unsigned long air = mesh.getTotalAirTime() / 1000UL; // seconds on air (tx) + unsigned long rxair = mesh.getReceiveAirTime() / 1000UL; // seconds on air (rx) + return snprintf(out, out_sz, + "rx F:%lu D:%lu | tx F:%lu D:%lu | air tx:%lus rx:%lus", + (unsigned long)mesh.getNumRecvFlood(), (unsigned long)mesh.getNumRecvDirect(), + (unsigned long)mesh.getNumSentFlood(), (unsigned long)mesh.getNumSentDirect(), + air, rxair); + } + + // Unknown command: stay silent (no reply), so typos and other bots' commands + // don't generate mesh traffic. + return 0; +} + +// ---- Entry point ------------------------------------------------------------ + +bool BotCommands::handle(MyMesh& mesh, const ContactInfo& from, mesh::Packet* pkt, const char* text) { + if (text == NULL || text[0] != BOT_CMD_PREFIX) return false; // not a bot command + + // Bot disabled (the "bot" custom var, set via meshcore-cli / phone app): + // stay completely silent; the message flows to the app as normal text. + if (!bot_config.isEnabled()) return false; + + unsigned long now = millis(); + if (!rateLimitOk(from.id.pub_key, now)) { + return true; // recognised as a command, but dropped to protect the airwaves + } + + // Split " " after the prefix. + char cmd[24]; + const char* sp = text + 1; + size_t ci = 0; + while (*sp && *sp != ' ' && ci < sizeof(cmd) - 1) cmd[ci++] = *sp++; + cmd[ci] = 0; + while (*sp == ' ') sp++; + const char* args = (*sp) ? sp : NULL; + + if (cmd[0] == 0) return true; // bare "!" with no command + + char reply[168]; // MAX_TEXT_LEN is ~160 + int len = buildReply(mesh, pkt, from.name, cmd, args, reply, sizeof(reply)); + if (len <= 0) return true; + if ((size_t)len >= sizeof(reply)) len = sizeof(reply) - 1; + reply[len] = 0; + + uint32_t expected_ack = 0, est_timeout = 0; + uint32_t ts = mesh.getRTCClock()->getCurrentTime(); + mesh.sendMessage(from, ts, 1, reply, expected_ack, est_timeout); + + _last_reply_ms = now; + _window_count++; + return true; +} + +// ---- Channel commands ------------------------------------------------------- + +bool BotCommands::channelAllowed(const char* name) { + if (name == NULL || name[0] == 0) return false; + const char* p = bot_config.channels(); // runtime allow-list ("" = none) + while (*p) { + const char* start = p; + while (*p && *p != ',') p++; // [start, p) is one entry + size_t entry_len = (size_t)(p - start); + if (entry_len > 0 && strlen(name) == entry_len && strncmp(name, start, entry_len) == 0) { + return true; + } + if (*p == ',') p++; + } + return false; +} + +bool BotCommands::handleChannel(MyMesh& mesh, const mesh::GroupChannel& channel, mesh::Packet* pkt, + uint32_t timestamp, const char* text) { + if (!bot_config.isEnabled()) return false; // bot disabled: fully inert on channels + if (text == NULL) return false; + // Channel payloads are formatted ": " (the sender name is + // part of the text, since a shared-key channel carries no contact identity). + // Skip that prefix to find the actual command. + const char* colon = strstr(text, ": "); + const char* body = colon ? (colon + 2) : text; + if (body[0] != BOT_CMD_PREFIX) return false; // not a bot command + + // Extract the sender display name (the part before ": ") for !ping. + char sender[20]; + size_t snlen = colon ? (size_t)(colon - text) : 0; + if (snlen >= sizeof(sender)) snlen = sizeof(sender) - 1; + memcpy(sender, text, snlen); + sender[snlen] = 0; + + // Resolve which channel this arrived on, and check the allow-list. + int idx = mesh.findChannelIdx(channel); + if (idx < 0) return false; + ChannelDetails ch; + if (!mesh.getChannel(idx, ch)) return false; + if (!channelAllowed(ch.name)) return false; + + unsigned long now = millis(); + // Rate-limit keyed by channel name (channel msgs carry no sender identity). + uint8_t ckey[4] = {0}; + for (int i = 0; i < 4 && ch.name[i]; i++) ckey[i] = (uint8_t)ch.name[i]; + if (!rateLimitOk(ckey, now)) return true; + + // Parse " " after the prefix. + char cmd[24]; + const char* sp = body + 1; + size_t ci = 0; + while (*sp && *sp != ' ' && ci < sizeof(cmd) - 1) cmd[ci++] = *sp++; + cmd[ci] = 0; + while (*sp == ' ') sp++; + const char* args = (*sp) ? sp : NULL; + if (cmd[0] == 0) return true; + + char reply[168]; + int len = buildReply(mesh, pkt, sender, cmd, args, reply, sizeof(reply)); + if (len <= 0) return true; + if ((size_t)len >= sizeof(reply)) len = sizeof(reply) - 1; + reply[len] = 0; + + // Post the reply back to the same channel. (Replies never start with the + // command prefix, so they cannot re-trigger the bot.) + mesh::GroupChannel chan = channel; // sendGroupMessage needs a non-const ref + mesh.sendGroupMessage(timestamp, chan, mesh.getNodePrefs()->node_name, reply, len); + + _last_reply_ms = now; + _window_count++; + return true; +} diff --git a/examples/companion_radio_bot/BotCommands.h b/examples/companion_radio_bot/BotCommands.h new file mode 100644 index 000000000..a0a7d5394 --- /dev/null +++ b/examples/companion_radio_bot/BotCommands.h @@ -0,0 +1,112 @@ +#pragma once + +#include +#include +#include + +/* ---------------------------------------------------------------------------- + * BotCommands + * + * Built-in "debug bot" for the BLE Companion firmware. When another node on + * the mesh sends this node a direct text message beginning with the command + * prefix ('!' by default), the bot generates a reply and sends it back over + * the mesh - in addition to the companion's normal behaviour of forwarding + * the incoming message to the connected phone app. + * + * All of the logic lives in examples/companion_radio_bot/ (hooked in via the + * BotMesh subclass) so normal companion builds are completely unaffected. + * BotCommands only uses MyMesh's public API (sendMessage, getNodePrefs, + * getRecentlyHeard, advert...), so it stays decoupled from the internals. + * + * Commands: + * !ping - the requester's signal into us (SNR/RSSI/hops) + * !rf - SNR + RSSI of the received request packet + * !path - route/hops the request arrived on (flood hashes) + * !status - node name, freq/SF/BW/CR, TX power, heap, contacts + * !stats - packet RX/TX counts + airtime (mesh health) + * !neighbors - recently-heard nodes (alias: !seen) + * !uptime - time since boot + * !time - node clock (UTC) if the RTC is set + * !ver - firmware version + build date + * + * Runtime configuration is NOT done over the mesh - it's set locally via the + * companion protocol's custom vars ("bot", "bot.channels"), e.g. with + * meshcore-cli, and persisted to flash. See BotConfig.h. + * ------------------------------------------------------------------------- */ + +#ifndef BOT_CMD_PREFIX +#define BOT_CMD_PREFIX '!' +#endif + +// Forward the incoming command message to the connected phone app as well +// (1 = phone still sees the "!cmd" message, 0 = suppress it from the app). +#ifndef BOT_FORWARD_TO_APP +#define BOT_FORWARD_TO_APP 1 +#endif + +// --- Rate limiting (anti-abuse: a flood of requests must not make us hammer +// the airwaves). All times in milliseconds / counts per window. --- + +// Minimum gap between ANY two bot replies, regardless of sender. +#ifndef BOT_MIN_REPLY_INTERVAL_MS +#define BOT_MIN_REPLY_INTERVAL_MS 1200 +#endif + +// Per-sender cooldown: ignore repeat commands from the same node within this. +#ifndef BOT_SENDER_COOLDOWN_MS +#define BOT_SENDER_COOLDOWN_MS 5000 +#endif + +// Hard cap on replies emitted within any rolling 60s window. +#ifndef BOT_MAX_REPLIES_PER_MIN +#define BOT_MAX_REPLIES_PER_MIN 20 +#endif + +#ifndef BOT_SENDER_TABLE_SIZE +#define BOT_SENDER_TABLE_SIZE 8 +#endif + +class MyMesh; + +class BotCommands { +public: + BotCommands() { reset(); } + + // Records boot time; call once from BotMesh::begin(). + void begin(); + + // Handle a freshly-received direct text message. Returns true if the text was + // a bot command (recognised or not, including ones dropped by rate limiting), + // so the caller knows it was "consumed" by the bot. + bool handle(MyMesh& mesh, const ContactInfo& from, mesh::Packet* pkt, const char* text); + + // Handle a text message posted to a group channel. Replies on the same + // channel if the channel is on the configured allow-list (the bot.channels + // custom var) and the text is a bot command. Returns true if it was a + // command on an allowed channel. + bool handleChannel(MyMesh& mesh, const mesh::GroupChannel& channel, mesh::Packet* pkt, + uint32_t timestamp, const char* text); + +private: + void reset(); + bool rateLimitOk(const uint8_t* key, unsigned long now); + static bool channelAllowed(const char* name); + + // Builds the reply text for a parsed command into `out` (capacity out_sz). + // `requester` is the sender's display name ("" if unknown). + // Returns the length written, or 0 if nothing should be sent. + int buildReply(MyMesh& mesh, mesh::Packet* pkt, const char* requester, + const char* cmd, const char* args, char* out, size_t out_sz); + + unsigned long _boot_millis; + + struct SenderSlot { + uint8_t prefix[4]; // first 4 bytes of sender public key (0 = empty) + unsigned long last_ms; + }; + SenderSlot _senders[BOT_SENDER_TABLE_SIZE]; + + unsigned long _last_reply_ms; // for global min-interval + unsigned long _window_start_ms; // start of the current 60s window + uint16_t _window_count; // replies emitted in current window +}; diff --git a/examples/companion_radio_bot/BotConfig.cpp b/examples/companion_radio_bot/BotConfig.cpp new file mode 100644 index 000000000..3e3e50a44 --- /dev/null +++ b/examples/companion_radio_bot/BotConfig.cpp @@ -0,0 +1,77 @@ +#include "BotConfig.h" +#include "MyMesh.h" // DataStore + FILESYSTEM definitions + +#include + +// The companion's DataStore instance, defined at global scope in the stock +// main.cpp (which the bot's main.cpp shim includes). Used only for the bot's +// own tiny settings file - DataStore's blob API is advert-specific. +extern DataStore store; + +BotConfig bot_config; + +void BotConfig::setDefaults() { + memset(&_s, 0, sizeof(_s)); + _s.magic = BOT_SETTINGS_MAGIC; + _s.version = BOT_SETTINGS_VERSION; + _s.enabled = 1; + // channels stays "" -> DM-only until a channel list is configured +} + +void BotConfig::ensureLoaded() { + if (_loaded) return; + _loaded = true; // only try once; missing/invalid file -> keep defaults + + File f = store.openRead(BOT_SETTINGS_FILE); + if (f) { + BotSettings tmp; + if (f.read((uint8_t*)&tmp, sizeof(tmp)) == sizeof(tmp) && + tmp.magic == BOT_SETTINGS_MAGIC && tmp.version == BOT_SETTINGS_VERSION) { + tmp.channels[sizeof(tmp.channels) - 1] = 0; + _s = tmp; + } + f.close(); + } +} + +bool BotConfig::save() { + FILESYSTEM* fs = store.getPrimaryFS(); + if (fs == NULL) return false; +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + fs->remove(BOT_SETTINGS_FILE); + File f = fs->open(BOT_SETTINGS_FILE, FILE_O_WRITE); +#elif defined(RP2040_PLATFORM) + File f = fs->open(BOT_SETTINGS_FILE, "w"); +#else + File f = fs->open(BOT_SETTINGS_FILE, "w", true); +#endif + if (!f) return false; + bool ok = f.write((const uint8_t*)&_s, sizeof(_s)) == sizeof(_s); + f.close(); + return ok; +} + +bool BotConfig::setEnabled(const char* value) { + if (value == NULL) return false; + ensureLoaded(); + if (!strcmp(value, "1") || !strcasecmp(value, "on") || !strcasecmp(value, "true")) { + _s.enabled = 1; + } else if (!strcmp(value, "0") || !strcasecmp(value, "off") || !strcasecmp(value, "false")) { + _s.enabled = 0; + } else { + return false; + } + return save(); +} + +bool BotConfig::setChannels(const char* value) { + ensureLoaded(); + if (value == NULL || value[0] == 0 || !strcasecmp(value, "none")) { + _s.channels[0] = 0; + } else if (strlen(value) < sizeof(_s.channels)) { + strcpy(_s.channels, value); + } else { + return false; // list too long + } + return save(); +} diff --git a/examples/companion_radio_bot/BotConfig.h b/examples/companion_radio_bot/BotConfig.h new file mode 100644 index 000000000..cb6844fb4 --- /dev/null +++ b/examples/companion_radio_bot/BotConfig.h @@ -0,0 +1,60 @@ +#pragma once + +#include + +/* ---------------------------------------------------------------------------- + * BotConfig + * + * Runtime bot configuration, persisted to flash so it survives reboots. + * Set locally over the companion protocol's custom-vars frames - e.g. via + * meshcore-cli or the phone app's custom variables - NOT via mesh messages, + * so only the paired/local user can change it: + * + * bot = 1 | 0 enable/disable all bot replies + * bot.channels = comma-separated channel allow-list the bot + * answers in (e.g. "#test,#bots"); "none" = the + * bot only answers direct messages + * + * Factory defaults: enabled, no channels (DM-only). + * ------------------------------------------------------------------------- */ + +#define BOT_SETTINGS_MAGIC 0xB0 +#define BOT_SETTINGS_VERSION 1 +#define BOT_SETTINGS_FILE "/bot_prefs" + +// Persisted verbatim; fixed size so a rewrite always fully overwrites the +// previous record (nRF52 FILE_O_WRITE does not truncate). +struct BotSettings { + uint8_t magic; // BOT_SETTINGS_MAGIC when the record is valid + uint8_t version; // BOT_SETTINGS_VERSION + uint8_t enabled; // 0 = bot completely silent + uint8_t reserved; + char channels[64]; // comma-separated channel allow-list ("" = none) +}; + +class BotConfig { +public: + BotConfig() { setDefaults(); } + + bool isEnabled() { ensureLoaded(); return _s.enabled != 0; } + const char* channels() { ensureLoaded(); return _s.channels; } + + // For the custom-vars GET frame: show "none" rather than an empty value. + const char* channelsForDisplay() { ensureLoaded(); return _s.channels[0] ? _s.channels : "none"; } + const char* enabledForDisplay() { ensureLoaded(); return _s.enabled ? "1" : "0"; } + + // Setters for the custom-vars SET frame; persist on success. + // Return false on an unparseable/oversized value (-> ERR frame to the app). + bool setEnabled(const char* value); + bool setChannels(const char* value); + +private: + void setDefaults(); + void ensureLoaded(); + bool save(); + + BotSettings _s; + bool _loaded = false; +}; + +extern BotConfig bot_config; diff --git a/examples/companion_radio_bot/BotMesh.h b/examples/companion_radio_bot/BotMesh.h new file mode 100644 index 000000000..75cd5d7b6 --- /dev/null +++ b/examples/companion_radio_bot/BotMesh.h @@ -0,0 +1,56 @@ +#pragma once + +#include "MyMesh.h" // stock companion mesh (examples/companion_radio) +#include "BotCommands.h" + +#include + +/* ---------------------------------------------------------------------------- + * BotMesh + * + * The companion mesh with the debug bot attached. This subclass is the ONLY + * coupling point between the bot and the stock companion firmware: it hooks + * the two virtual receive callbacks and begin(), and delegates everything + * else to MyMesh untouched. The stock sources in examples/companion_radio + * are compiled as-is, so upstream pulls never conflict with the bot. + * ------------------------------------------------------------------------- */ +class BotMesh : public MyMesh { +public: + // Forward whatever constructor signature upstream's main.cpp uses, so this + // doesn't need updating when MyMesh's constructor changes. + template + BotMesh(Args&&... args) : MyMesh(std::forward(args)...) {} + + // NOTE: not virtual in MyMesh — main.cpp calls this through the BotMesh + // object directly, so this shadowing override is what runs at startup. + void begin(bool has_display) { + MyMesh::begin(has_display); + _bot.begin(); + } + +protected: + void onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, + const char *text) override { + // Intercept '!'-prefixed debug commands and auto-reply over the mesh. + bool was_bot_cmd = _bot.handle(*this, from, pkt, text); + #if BOT_FORWARD_TO_APP == 0 + if (was_bot_cmd) { + markConnectionActive(from); // the skipped base handler would have done this + return; // suppress the command message from the phone app + } + #else + (void)was_bot_cmd; + #endif + MyMesh::onMessageRecv(from, pkt, sender_timestamp, text); + } + + void onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp, + const char *text) override { + // Answer '!'-prefixed commands posted to an allow-listed channel. + _bot.handleChannel(*this, channel, pkt, timestamp, text); + MyMesh::onChannelMessageRecv(channel, pkt, timestamp, text); + } + +private: + BotCommands _bot; +}; diff --git a/examples/companion_radio_bot/BotMyMesh.cpp b/examples/companion_radio_bot/BotMyMesh.cpp new file mode 100644 index 000000000..c34182970 --- /dev/null +++ b/examples/companion_radio_bot/BotMyMesh.cpp @@ -0,0 +1,32 @@ +/* ---------------------------------------------------------------------------- + * Compiles the stock MyMesh.cpp with all uses of the global `sensors` + * object routed through botSensorsRef(). + * + * Why: `sensors` is a global OBJECT, so the compiler can prove its exact + * dynamic type and devirtualize calls like sensors.getNumSettings() into + * direct calls to the stock manager's methods, bypassing the + * BotSensorManagerT overrides that add the bot's custom vars (confirmed in + * the disassembly: literal-pool call, no vtable load). An out-of-line + * accessor returning a base-class reference makes the dynamic type opaque + * again, so the custom-vars frames dispatch through the vtable and reach + * the wrapper. + * + * The stock headers are pre-included before the macro is defined (they are + * all #pragma once), so the macro only rewrites MyMesh.cpp's own code - + * notably NOT target.h's extern declaration, nor other headers' function + * parameters that happen to be named `sensors` (e.g. UITask::begin). + * Everything MyMesh.cpp does with `sensors` is plain member access + * (methods + node_lat/node_lon fields), all present on SensorManager. + * + * The bot env excludes the stock MyMesh.cpp and compiles this instead; + * resolves through the env's -I examples/companion_radio. + * ------------------------------------------------------------------------- */ +#include "MyMesh.h" +#include +#include + +SensorManager& botSensorsRef(); // defined in BotTarget.cpp + +#define sensors (botSensorsRef()) +#include +#undef sensors diff --git a/examples/companion_radio_bot/BotSensorManager.h b/examples/companion_radio_bot/BotSensorManager.h new file mode 100644 index 000000000..48bdfa7e1 --- /dev/null +++ b/examples/companion_radio_bot/BotSensorManager.h @@ -0,0 +1,49 @@ +#pragma once + +#include "BotConfig.h" + +#include + +/* ---------------------------------------------------------------------------- + * BotSensorManagerT + * + * Wraps the board's sensor manager to add the bot's settings as standard + * companion-protocol "custom vars" (CMD_GET_CUSTOM_VARS / CMD_SET_CUSTOM_VAR + * route through the global `sensors` object's setting accessors), alongside + * the stock ones like "gps": + * + * bot = 1 | 0 + * bot.channels = | none + * + * Templated on the concrete manager class because boards differ (most use + * EnvironmentSensorManager, the T1000-E has its own T1000SensorManager). + * Instantiated in BotTarget.cpp, which swaps the global `sensors` object + * for this wrapper. + * ------------------------------------------------------------------------- */ +template +class BotSensorManagerT : public BASE { +public: + BotSensorManagerT(const BASE& src) : BASE(src) {} + + int getNumSettings() const override { return BASE::getNumSettings() + 2; } + + const char* getSettingName(int i) const override { + int n = BASE::getNumSettings(); + if (i == n) return "bot"; + if (i == n + 1) return "bot.channels"; + return BASE::getSettingName(i); + } + + const char* getSettingValue(int i) const override { + int n = BASE::getNumSettings(); + if (i == n) return bot_config.enabledForDisplay(); + if (i == n + 1) return bot_config.channelsForDisplay(); + return BASE::getSettingValue(i); + } + + bool setSettingValue(const char* name, const char* value) override { + if (strcmp(name, "bot") == 0) return bot_config.setEnabled(value); + if (strcmp(name, "bot.channels") == 0) return bot_config.setChannels(value); + return BASE::setSettingValue(name, value); + } +}; diff --git a/examples/companion_radio_bot/BotTarget.cpp b/examples/companion_radio_bot/BotTarget.cpp new file mode 100644 index 000000000..edaebfb06 --- /dev/null +++ b/examples/companion_radio_bot/BotTarget.cpp @@ -0,0 +1,34 @@ +/* ---------------------------------------------------------------------------- + * Compiles the stock variants//target.cpp with the global `sensors` + * object swapped for a BotSensorManagerT wrapper, which adds the bot's + * settings ("bot", "bot.channels") to the companion protocol's custom-vars + * frames - settable from meshcore-cli / the phone app, persisted to flash. + * + * Each bot env excludes the stock target.cpp from the build and compiles + * this file instead. resolves through the board's existing + * "-I variants/" include path, so this file is board-agnostic. + * + * The rename trick: while target.cpp is included, `sensors` is #define'd to + * `bot_stock_sensors`, so the stock manager - with all its board-specific + * type and constructor args - is built under that name (and target.h's + * extern declaration is renamed consistently within this translation unit). + * The real `sensors` symbol is then defined below as the wrapper, copy- + * constructed from the stock instance; decltype picks up whatever concrete + * manager class the board uses. Other translation units (MyMesh.cpp etc.) + * declare `extern EnvironmentSensorManager sensors` and dispatch through + * the object's vtable, picking up the bot's overrides - same single- + * inheritance aliasing trick the bot already uses for `the_mesh`. + * ------------------------------------------------------------------------- */ +#define sensors bot_stock_sensors +#include +#undef sensors + +#include "BotSensorManager.h" + +BotSensorManagerT sensors(bot_stock_sensors); + +// Out-of-line accessor used by BotMyMesh.cpp: returning the wrapper through +// a base-class reference hides its dynamic type from the compiler, so the +// settings calls in MyMesh.cpp stay virtual instead of being devirtualized +// straight to the stock manager (see BotMyMesh.cpp). +SensorManager& botSensorsRef() { return sensors; } diff --git a/examples/companion_radio_bot/main.cpp b/examples/companion_radio_bot/main.cpp new file mode 100644 index 000000000..d5bbaadbb --- /dev/null +++ b/examples/companion_radio_bot/main.cpp @@ -0,0 +1,30 @@ +/* ---------------------------------------------------------------------------- + * Companion-radio-with-bot firmware entry point. + * + * Reuses the stock companion's main.cpp verbatim (textual include) instead of + * carrying a copy, so upstream changes to board/interface setup are picked up + * automatically. The only difference vs. stock: the global mesh object is a + * BotMesh (our subclass) named `the_bot_mesh`. + * + * Other translation units (MyMesh.cpp, UITask.cpp) link against the symbol + * `the_mesh` declared extern in MyMesh.h; the bot env aliases it to our + * instance with: -Wl,--defsym,the_mesh=the_bot_mesh + * (C++ forbids defining a BotMesh under an `extern MyMesh the_mesh;` + * declaration, hence the linker-level alias.) + * ------------------------------------------------------------------------- */ + +#include "BotMesh.h" + +// Pre-include headers that upstream main.cpp pulls in, so they are processed +// BEFORE the token renames below and can never be rewritten by them. +#ifdef DISPLAY_CLASS + #include "UITask.h" +#endif + +#define MyMesh BotMesh // make upstream main.cpp instantiate our subclass +#define the_mesh the_bot_mesh // ...under a name that doesn't clash with the extern + +#include "../companion_radio/main.cpp" + +#undef the_mesh +#undef MyMesh diff --git a/variants/bot_envs/platformio.ini b/variants/bot_envs/platformio.ini new file mode 100644 index 000000000..f5d7bcc94 --- /dev/null +++ b/variants/bot_envs/platformio.ini @@ -0,0 +1,128 @@ +; Companion (BLE) firmware + built-in debug bot (!ping, !rf, !path, ...). +; All bot code lives in examples/companion_radio_bot/ and this file, so the +; upstream tree stays pristine and pulls never conflict. +; See examples/companion_radio_bot/BOT_README.md. +; +; Each env inherits everything from the board's stock BLE companion env; +; the deltas are always the same: +; - compile examples/companion_radio_bot/ instead of the stock main.cpp +; - alias the `the_mesh` symbol to our BotMesh instance (see bot main.cpp) +; - compile target.cpp via BotTarget.cpp so the bot's settings ("bot", +; "bot.channels") show up as companion-protocol custom vars +; Optional (per env): -D BOT_FORWARD_TO_APP=0 + +[env:Xiao_S3_WIO_companion_radio_ble_bot] +extends = env:Xiao_S3_WIO_companion_radio_ble +upload_speed = 115200 ; S3 native USB is flaky at the 460800 default on macOS +build_flags = + ${env:Xiao_S3_WIO_companion_radio_ble.build_flags} + -I examples/companion_radio + -D ADVERT_NAME='"XiaoS3 Bot"' + -Wl,--defsym,the_mesh=the_bot_mesh +build_src_filter = + ${env:Xiao_S3_WIO_companion_radio_ble.build_src_filter} + -<../examples/companion_radio/main.cpp> + -<../examples/companion_radio/MyMesh.cpp> + -<../variants/xiao_s3_wio/target.cpp> + +<../examples/companion_radio_bot/*.cpp> + +[env:Heltec_v3_companion_radio_ble_bot] +extends = env:Heltec_v3_companion_radio_ble +build_flags = + ${env:Heltec_v3_companion_radio_ble.build_flags} + -I examples/companion_radio + -D ADVERT_NAME='"HeltecV3 Bot"' + -Wl,--defsym,the_mesh=the_bot_mesh +build_src_filter = + ${env:Heltec_v3_companion_radio_ble.build_src_filter} + -<../examples/companion_radio/main.cpp> + -<../examples/companion_radio/MyMesh.cpp> + -<../variants/heltec_v3/target.cpp> + +<../examples/companion_radio_bot/*.cpp> + +[env:RAK_4631_companion_radio_ble_bot] +extends = env:RAK_4631_companion_radio_ble +build_flags = + ${env:RAK_4631_companion_radio_ble.build_flags} + -I examples/companion_radio + -D ADVERT_NAME='"RAK4631 Bot"' + -Wl,--defsym,the_mesh=the_bot_mesh +build_src_filter = + ${env:RAK_4631_companion_radio_ble.build_src_filter} + -<../examples/companion_radio/main.cpp> + -<../examples/companion_radio/MyMesh.cpp> + -<../variants/rak4631/target.cpp> + +<../examples/companion_radio_bot/*.cpp> + +; No ADVERT_NAME here: the stock t1000e env already defines it ('"@@MAC"'); +; set the node name from the phone app instead. +[env:t1000e_companion_radio_ble_bot] +extends = env:t1000e_companion_radio_ble +build_flags = + ${env:t1000e_companion_radio_ble.build_flags} + -I examples/companion_radio + -Wl,--defsym,the_mesh=the_bot_mesh +build_src_filter = + ${env:t1000e_companion_radio_ble.build_src_filter} + -<../examples/companion_radio/main.cpp> + -<../examples/companion_radio/MyMesh.cpp> + -<../variants/t1000-e/target.cpp> + +<../examples/companion_radio_bot/*.cpp> + +[env:Heltec_t114_companion_radio_ble_bot] +extends = env:Heltec_t114_companion_radio_ble +build_flags = + ${env:Heltec_t114_companion_radio_ble.build_flags} + -I examples/companion_radio + -D ADVERT_NAME='"T114 Bot"' + -Wl,--defsym,the_mesh=the_bot_mesh +build_src_filter = + ${env:Heltec_t114_companion_radio_ble.build_src_filter} + -<../examples/companion_radio/main.cpp> + -<../examples/companion_radio/MyMesh.cpp> + -<../variants/heltec_t114/target.cpp> + +<../examples/companion_radio_bot/*.cpp> + +[env:heltec_v4_companion_radio_ble_bot] +extends = env:heltec_v4_companion_radio_ble +build_flags = + ${env:heltec_v4_companion_radio_ble.build_flags} + -I examples/companion_radio + -D ADVERT_NAME='"HeltecV4 Bot"' + -Wl,--defsym,the_mesh=the_bot_mesh +build_src_filter = + ${env:heltec_v4_companion_radio_ble.build_src_filter} + -<../examples/companion_radio/main.cpp> + -<../examples/companion_radio/MyMesh.cpp> + -<../variants/heltec_v4/target.cpp> + +<../examples/companion_radio_bot/*.cpp> + +; Seeed XIAO nRF52840 + Wio-SX1262 +[env:Xiao_nrf52_companion_radio_ble_bot] +extends = env:Xiao_nrf52_companion_radio_ble +build_flags = + ${env:Xiao_nrf52_companion_radio_ble.build_flags} + -I examples/companion_radio + -D ADVERT_NAME='"XiaoNRF Bot"' + -Wl,--defsym,the_mesh=the_bot_mesh +build_src_filter = + ${env:Xiao_nrf52_companion_radio_ble.build_src_filter} + -<../examples/companion_radio/main.cpp> + -<../examples/companion_radio/MyMesh.cpp> + -<../variants/xiao_nrf52/target.cpp> + +<../examples/companion_radio_bot/*.cpp> + +; RAK WisMesh 1W Booster (RAK3401 + RAK13302) +[env:RAK_3401_companion_radio_ble_bot] +extends = env:RAK_3401_companion_radio_ble +build_flags = + ${env:RAK_3401_companion_radio_ble.build_flags} + -I examples/companion_radio + -D ADVERT_NAME='"RAK3401 Bot"' + -Wl,--defsym,the_mesh=the_bot_mesh +build_src_filter = + ${env:RAK_3401_companion_radio_ble.build_src_filter} + -<../examples/companion_radio/main.cpp> + -<../examples/companion_radio/MyMesh.cpp> + -<../variants/rak3401/target.cpp> + +<../examples/companion_radio_bot/*.cpp>