mirror of https://github.com/meshcore-dev/MeshCore
Browse Source
A separate firmware type layering a mesh debug bot on top of the stock
BLE companion example without modifying upstream sources. Replies to
!-prefixed commands (!ping, !rf, !path, !status, !stats, !neighbors,
!uptime, !time, !ver) from direct messages or allow-listed channels,
with rate limiting.
Runtime config rides the companion protocol's custom vars ("bot",
"bot.channels") settable via meshcore-cli or the phone app, persisted
to flash. Envs for Xiao S3 WIO, Xiao nRF52, Heltec V3, Heltec V4,
RAK4631, RAK3401 (WisMesh 1W Booster), T1000-E and Heltec T114 in
variants/bot_envs/platformio.ini.
pull/2755/head
11 changed files with 1051 additions and 0 deletions
@ -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: `<name>: 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/<board>/`) 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/<env>/` 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. |
||||
@ -0,0 +1,310 @@ |
|||||
|
#include "BotCommands.h" |
||||
|
#include "BotConfig.h" |
||||
|
#include "MyMesh.h" // full MyMesh definition + target.h (radio_driver, sensors) |
||||
|
|
||||
|
#include <string.h> |
||||
|
#include <stdio.h> |
||||
|
|
||||
|
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 "<cmd> <args>" 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 "<sender>: <message>" (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 "<cmd> <args>" 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; |
||||
|
} |
||||
@ -0,0 +1,112 @@ |
|||||
|
#pragma once |
||||
|
|
||||
|
#include <Arduino.h> |
||||
|
#include <Mesh.h> |
||||
|
#include <helpers/ContactInfo.h> |
||||
|
|
||||
|
/* ----------------------------------------------------------------------------
|
||||
|
* 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
|
||||
|
}; |
||||
@ -0,0 +1,77 @@ |
|||||
|
#include "BotConfig.h" |
||||
|
#include "MyMesh.h" // DataStore + FILESYSTEM definitions |
||||
|
|
||||
|
#include <string.h> |
||||
|
|
||||
|
// 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(); |
||||
|
} |
||||
@ -0,0 +1,60 @@ |
|||||
|
#pragma once |
||||
|
|
||||
|
#include <Arduino.h> |
||||
|
|
||||
|
/* ----------------------------------------------------------------------------
|
||||
|
* 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 = <list> 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; |
||||
@ -0,0 +1,56 @@ |
|||||
|
#pragma once |
||||
|
|
||||
|
#include "MyMesh.h" // stock companion mesh (examples/companion_radio) |
||||
|
#include "BotCommands.h" |
||||
|
|
||||
|
#include <utility> |
||||
|
|
||||
|
/* ----------------------------------------------------------------------------
|
||||
|
* 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 <typename... Args> |
||||
|
BotMesh(Args&&... args) : MyMesh(std::forward<Args>(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; |
||||
|
}; |
||||
@ -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; |
||||
|
* <MyMesh.cpp> resolves through the env's -I examples/companion_radio. |
||||
|
* ------------------------------------------------------------------------- */ |
||||
|
#include "MyMesh.h" |
||||
|
#include <Arduino.h> |
||||
|
#include <Mesh.h> |
||||
|
|
||||
|
SensorManager& botSensorsRef(); // defined in BotTarget.cpp
|
||||
|
|
||||
|
#define sensors (botSensorsRef()) |
||||
|
#include <MyMesh.cpp> |
||||
|
#undef sensors |
||||
@ -0,0 +1,49 @@ |
|||||
|
#pragma once |
||||
|
|
||||
|
#include "BotConfig.h" |
||||
|
|
||||
|
#include <string.h> |
||||
|
|
||||
|
/* ----------------------------------------------------------------------------
|
||||
|
* 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 = <comma-separated list> | 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 BASE> |
||||
|
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); |
||||
|
} |
||||
|
}; |
||||
@ -0,0 +1,34 @@ |
|||||
|
/* ----------------------------------------------------------------------------
|
||||
|
* Compiles the stock variants/<board>/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. <target.cpp> resolves through the board's existing |
||||
|
* "-I variants/<board>" 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 <target.cpp> |
||||
|
#undef sensors |
||||
|
|
||||
|
#include "BotSensorManager.h" |
||||
|
|
||||
|
BotSensorManagerT<decltype(bot_stock_sensors)> 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; } |
||||
@ -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 |
||||
@ -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> |
||||
Loading…
Reference in new issue