diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 1dd2257b..5d006f26 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -329,6 +329,8 @@ static void sanitize(NodePrefs& p) { flag(p.bot_actions_ch, 0); flag(p.bot_actions_room, 0); flag(p.bot_dm_scope, 0); + flag(p.bot_pos_scope, 0); + if (p.bot_gap_idx >= NodePrefs::BOT_GAP_COUNT) p.bot_gap_idx = 0; flag(p.bot_room_enabled, 0); if (p.bot_quiet_start > 23) p.bot_quiet_start = 0; if (p.bot_quiet_end > 23) p.bot_quiet_end = 0; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 2b1b1abb..129897ce 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,5 +1,6 @@ #include "MyMesh.h" #include "MsgExpand.h" +#include "ui-core/BotCommands.h" #include "GeoUtils.h" #include "Features.h" diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 2cfcc442..27b63c49 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -185,6 +185,7 @@ public: virtual void onChannelRemoved(uint8_t channel_idx) { } // Remote bot device actions (!gps/!buzz/!gpioN), gated by bot_actions_* prefs. virtual void botSetGPS(bool on) { } + virtual bool botGpsHeld() { return false; } // an SOS has the GPS: !gps is refused virtual void botBuzz(int seconds) { } virtual bool botSetGPIO(int idx, bool on) { return false; } virtual bool botGetGPIO(int idx, bool& is_output, bool& value) { return false; } @@ -614,8 +615,9 @@ private: bool tryBotCommand(const ContactInfo& from, const char* text, uint8_t hops); // DM commands bool tryBotChannelCommand(uint8_t channel_idx, const char* text, uint8_t hops); // channel commands bool tryBotRoomCommand(const ContactInfo& from, const uint8_t* sender_prefix, const char* text, uint8_t hops); // room commands - bool botCommandReply(const char* cmd, const char* arg, const char* arg2, bool actions_allowed, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name); // one command → reply text - int botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name, bool actions_allowed); // scan "!word"s → combined reply, returns count + bool botGapPassed(unsigned long last) const; // the Reply gap since a channel / room reply + bool botCommandReply(const char* cmd, const char* arg, const char* arg2, bool actions_allowed, uint16_t cmd_off, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name); // one command → reply text + int botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name, bool actions_allowed, uint16_t cmd_off); // scan "!word"s → combined reply, returns count // !gps fix -- single-shot "wait for a stabilised GPS fix, then push a follow-up // message" action. botCommandReply() only sets _locfix_requested (it doesn't know // the destination); the tryBot*Command() wrappers call startLocFix() with the diff --git a/examples/companion_radio/MyMeshBot.h b/examples/companion_radio/MyMeshBot.h index 808d29a5..f36085b6 100644 --- a/examples/companion_radio/MyMeshBot.h +++ b/examples/companion_radio/MyMeshBot.h @@ -2,15 +2,20 @@ // Bot logic — not part of upstream MyMesh.cpp. // Included at the bottom of MyMesh.cpp after all class definitions. -// Minimum gap between two auto-replies to the same contact (DM), on the bot -// channel, or on the bot room. Guards against floods and, with -// botTriggerMatches()'s loop check, against bot↔bot ping-pong on a shared -// channel/room. +// Minimum gap between two auto-replies to the same contact (DM). Guards against +// floods; the bot channel and room have their own setting, see botGapPassed(). static const unsigned long BOT_REPLY_COOLDOWN_MS = 10000UL; // Scratch buffer size for trigger matching / reply expansion. Covers the full // incoming message (MAX_TEXT_LEN ~160) plus the 140-byte reply templates. static const int BOT_SCRATCH = 200; +// Reply gap on the bot channel / room: airtime for everyone in range, and with +// botTriggerMatches()'s loop check it guards against bot<->bot ping-pong. last +// is 0 until the first reply: the wait isn't owed from boot. +bool MyMesh::botGapPassed(unsigned long last) const { + return !last || millis() - last > (unsigned long)NodePrefs::botGapSecs(_prefs.bot_gap_idx) * 1000UL; +} + // Case-insensitive (ASCII) substring test: does `body` contain any one of // `trigger`'s comma-separated phrases? A lone "*" trigger (the whole field, // not one comma-separated phrase) means "match everything" (away / reply-to- @@ -209,7 +214,7 @@ void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text, uint8_t h // the tabbed BotScreen UI already treat DM/channel/room as separate targets. if (!(_prefs.bot_channel_enabled && _prefs.bot_reply_ch[0] && channel_idx == _prefs.bot_channel_idx && - millis() - _bot_last_ch_reply_ms > BOT_REPLY_COOLDOWN_MS)) + botGapPassed(_bot_last_ch_reply_ms))) return; if (botInQuietHours()) return; @@ -263,7 +268,7 @@ void MyMesh::tryBotReplyRoom(const ContactInfo& from, const uint8_t* sender_pref // bot_enabled, same reasoning as tryBotReplyChannel above. if (!(_prefs.bot_room_enabled && _prefs.bot_reply_room[0] && memcmp(from.id.pub_key, _prefs.bot_room_prefix, NodePrefs::FAVOURITE_PREFIX_LEN) == 0 && - millis() - _bot_last_room_reply_ms > BOT_REPLY_COOLDOWN_MS)) + botGapPassed(_bot_last_room_reply_ms))) return; if (botInQuietHours()) return; if (!botTriggerMatches(_prefs.bot_trigger_room, text, true)) return; // "*" = any post @@ -301,7 +306,7 @@ void MyMesh::tryBotReplyRoom(const ContactInfo& from, const uint8_t* sender_pref // expandMsg's placeholders, including {name}/{hops} if the sender wrote them // into a custom reply — not used by any of the built-in templates below today. bool MyMesh::botCommandReply(const char* cmd, const char* arg, const char* arg2, bool actions_allowed, - uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name) { + uint16_t cmd_off, uint8_t hops, uint32_t ts, char* out, int out_len, const char* sender_name) { if (!strcmp(cmd, "path")) { strncpy(out, s_bot_path, out_len); // "direct" or the hop hashes, oldest first out[out_len - 1] = '\0'; @@ -348,7 +353,9 @@ bool MyMesh::botCommandReply(const char* cmd, const char* arg, const char* arg2, // covers LOCFIX_AVERAGE_MS (no point requesting less than one averaging // window), ceiling keeps a forgotten/misfired request from parking GPS // on indefinitely. + if (_listener && _listener->botGpsHeld()) { snprintf(out, out_len, "GPS: on for SOS"); return true; } if (!strcmp(arg, "fix")) { + if (!botcmd::position(cmd_off)) { snprintf(out, out_len, "GPS: fix off"); return true; } if (_loc_fix.active) { snprintf(out, out_len, "GPS: fix already pending"); return true; } uint32_t timeout_ms = LOCFIX_TIMEOUT_MS; if (arg2[0]) { @@ -429,10 +436,8 @@ bool MyMesh::botCommandReply(const char* cmd, const char* arg, const char* arg2, else if (!strcmp(cmd, "loc")) tmpl = "{loc}"; else if (!strcmp(cmd, "time")) tmpl = "{time}"; else if (!strcmp(cmd, "temp")) tmpl = "{temp}"; - else if (!strcmp(cmd, "status")) tmpl = "batt {batt} loc {loc} at {time}"; - else if (!strcmp(cmd, "help")) tmpl = actions_allowed - ? "cmds: !ping !batt !loc !time !temp !hops !path !snr !dist !status !gps !buzz !advert !gpio1-4" - : "cmds: !ping !batt !loc !time !temp !hops !path !snr !dist !status"; + else if (!strcmp(cmd, "status")) tmpl = botcmd::position(cmd_off) ? "batt {batt} loc {loc} at {time}" : "batt {batt} at {time}"; + else if (!strcmp(cmd, "help")) { botcmd::help(out, out_len, cmd_off, actions_allowed); return true; } else return false; // unknown — let the trigger bot try expandMsg(tmpl, out, out_len, @@ -449,9 +454,10 @@ bool MyMesh::botCommandReply(const char* cmd, const char* arg, const char* arg2, // combined reply (segments joined by " | ") into out. Returns how many commands // were recognised. Shared by the DM, channel and room command paths. // actions_allowed gates the state-changing commands (!gps/!buzz/!advert) -- -// the read-only queries are always reachable once a target's Commands is on. +// the read-only queries are reachable once a target's Commands is on. cmd_off: +// the ones this target doesn't answer, treated like a word it doesn't know. int MyMesh::botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* out, int out_len, - const char* sender_name, bool actions_allowed) { + const char* sender_name, bool actions_allowed, uint16_t cmd_off) { resetPendingBotActions(); // pending actions/flags below are set by tokens in this scan; // caller applies or clears them once it knows if the reply sent // Reads one space-delimited token at p into buf (lowercased, truncated to @@ -465,7 +471,9 @@ int MyMesh::botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* o }; int oi = 0; int matched = 0; - for (const char* p = body; *p; ) { + const char* p = botcmd::start(body); + if (!p) return 0; + while (*p) { while (*p == ' ') p++; // skip whitespace if (*p != '!') { // not a command token while (*p && *p != ' ') p++; @@ -492,8 +500,10 @@ int MyMesh::botScanCommands(const char* body, uint8_t hops, uint32_t ts, char* o if (*p && *p != '!') readToken(p, arg2, sizeof(arg2)); } - char seg[80]; - if (!botCommandReply(cmd, arg1, arg2, actions_allowed, hops, ts, seg, sizeof(seg), sender_name)) continue; // unknown + if (!botcmd::enabled(cmd, cmd_off)) continue; + + char seg[100]; // the whole of !help + if (!botCommandReply(cmd, arg1, arg2, actions_allowed, cmd_off, hops, ts, seg, sizeof(seg), sender_name)) continue; // unknown if (matched > 0 && oi + 3 < out_len) { // " | " separator out[oi++] = ' '; out[oi++] = '|'; out[oi++] = ' '; @@ -515,9 +525,12 @@ bool MyMesh::tryBotCommand(const ContactInfo& from, const char* text, uint8_t ho if (from.type != ADV_TYPE_CHAT) return false; if (!botDmSenderAllowed(from)) return false; + uint16_t cmd_off = _prefs.bot_cmd_off_dm; + if (_prefs.bot_pos_scope && !(from.flags & 0x01)) cmd_off |= botcmd::flag(botcmd::LOC); // a DM's sender is verified, unlike a channel's + uint32_t ts = getRTCClock()->getCurrentTime(); char out[BOT_SCRATCH]; - if (botScanCommands(text, hops, ts, out, sizeof(out), from.name, _prefs.bot_actions_dm != 0 && botActionsFrom(&from)) == 0) return false; // no commands + if (botScanCommands(text, hops, ts, out, sizeof(out), from.name, _prefs.bot_actions_dm != 0 && botActionsFrom(&from), cmd_off) == 0) return false; // no commands if (!botDmAllowed(from.id.pub_key)) { resetPendingBotActions(); return true; } // throttled uint32_t expected_ack, est_timeout; @@ -550,9 +563,9 @@ bool MyMesh::tryBotChannelCommand(uint8_t channel_idx, const char* text, uint8_t uint32_t ts = getRTCClock()->getCurrentTime(); char out[BOT_SCRATCH]; - if (botScanCommands(msg, hops, ts, out, sizeof(out), sender_name, _prefs.bot_actions_ch != 0 && botActionsFrom(s_bot_sender)) == 0) return false; // no commands + if (botScanCommands(msg, hops, ts, out, sizeof(out), sender_name, _prefs.bot_actions_ch != 0 && botActionsFrom(s_bot_sender), _prefs.bot_cmd_off_ch) == 0) return false; // no commands if (botInQuietHours()) { resetPendingBotActions(); return true; } // quiet hours - if (millis() - _bot_last_ch_reply_ms <= BOT_REPLY_COOLDOWN_MS) { resetPendingBotActions(); return true; } // throttled + if (!botGapPassed(_bot_last_ch_reply_ms)) { resetPendingBotActions(); return true; } // throttled ChannelDetails ch; if (!getChannel(channel_idx, ch)) { resetPendingBotActions(); return true; } @@ -585,9 +598,9 @@ bool MyMesh::tryBotRoomCommand(const ContactInfo& from, const uint8_t* sender_pr uint32_t ts = getRTCClock()->getCurrentTime(); char out[BOT_SCRATCH]; - if (botScanCommands(text, hops, ts, out, sizeof(out), sender_name, _prefs.bot_actions_room != 0 && botActionsFrom(s_bot_sender)) == 0) return false; // no commands + if (botScanCommands(text, hops, ts, out, sizeof(out), sender_name, _prefs.bot_actions_room != 0 && botActionsFrom(s_bot_sender), _prefs.bot_cmd_off_room) == 0) return false; // no commands if (botInQuietHours()) { resetPendingBotActions(); return true; } // quiet hours - if (millis() - _bot_last_room_reply_ms <= BOT_REPLY_COOLDOWN_MS) { resetPendingBotActions(); return true; } // throttled + if (!botGapPassed(_bot_last_room_reply_ms)) { resetPendingBotActions(); return true; } // throttled uint32_t expected_ack, est_timeout; if (sendMessage(from, ts, 0, out, expected_ack, est_timeout) != MSG_SEND_FAILED) { diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index eaec6168..e7f46bdf 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -419,6 +419,13 @@ struct NodePrefsFields { uint8_t bot_quiet_start; // quiet-hours start hour, local 0-23 (start==end → disabled) uint8_t bot_quiet_end; // quiet-hours end hour, local 0-23 uint8_t bot_actions_scope; // who may run the bot's actions (!gps !buzz !gpio ...): 0 = all, 1 = favourites + // Commands each target doesn't answer, bits of botcmd::Id (ui-core/BotCommands.h); + // 0 = all of them. Under Commands / Actions, which stay the on/off switches. + uint16_t bot_cmd_off_dm; + uint16_t bot_cmd_off_ch; + uint16_t bot_cmd_off_room; + uint8_t bot_pos_scope; // who gets the position in a DM: 0 = all, 1 = favourites (the others as if !loc were off) + uint8_t bot_gap_idx; // index into botGapSecs(): between replies on the channel / in the room // ── GPS, trail & location sharing ───────────────────────────────────── uint8_t gps_enabled; // GPS enabled flag (0=disabled, 1=enabled) @@ -649,6 +656,11 @@ struct NodePrefsFields { static const char* L[CONTACT_EXPIRY_COUNT] = { "Off", "7d", "30d", "90d" }; return L[idx < CONTACT_EXPIRY_COUNT ? idx : 0]; } + static const uint8_t BOT_GAP_COUNT = 4; + static uint16_t botGapSecs(uint8_t idx) { + static const uint16_t S[BOT_GAP_COUNT] = { 10, 30, 60, 300 }; + return S[idx < BOT_GAP_COUNT ? idx : 0]; + } static const uint8_t MSG_KEEP_COUNT = 4; static uint16_t msgKeepTotal(uint8_t idx) { static const uint16_t N[MSG_KEEP_COUNT] = { 0, 500, 1000, 2500 }; @@ -879,6 +891,8 @@ class NodePrefs : public NodePrefsFields, public ConfigSerializer { s.def("rm_reply", bot_reply_room, sizeof(bot_reply_room)); s.def("rm_cmd", bot_commands_room); s.def("rm_act", bot_actions_room); s.def("q_start", bot_quiet_start); s.def("q_end", bot_quiet_end); s.def("act_scope", bot_actions_scope); + s.def("dm_off", bot_cmd_off_dm); s.def("ch_off", bot_cmd_off_ch); s.def("rm_off", bot_cmd_off_room); + s.def("pos_scope", bot_pos_scope); s.def("gap", bot_gap_idx); } void gpsSolo(Section& s) { s.def("imperial", units_imperial); diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 9a834702..3f375537 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -664,14 +664,17 @@ extern "C" EMSCRIPTEN_KEEPALIVE const char* sim_test_contact_hash(int type) { // Turns on the DM bot's commands (Tools > Bot > Direct > Commands), with its // actions and who may run them (Other > Actions by: 0 all, 1 favourites), so -// a host page can test the bot from a peer. In memory only. -extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_bot(int actions, int actions_scope) { +// a host page can test the bot from a peer; cmd_off: the commands it doesn't +// answer (bot_cmd_off_dm), pos_scope: Direct > Position. In memory only. +extern "C" EMSCRIPTEN_KEEPALIVE int sim_test_bot(int actions, int actions_scope, int cmd_off, int pos_scope) { if (!g_sim_ready) return 0; NodePrefs* prefs = the_mesh.getNodePrefs(); if (!prefs) return 0; prefs->bot_commands_enabled = 1; prefs->bot_actions_dm = actions ? 1 : 0; prefs->bot_actions_scope = actions_scope ? 1 : 0; + prefs->bot_cmd_off_dm = (uint16_t)cmd_off; + prefs->bot_pos_scope = pos_scope ? 1 : 0; return 1; } diff --git a/examples/companion_radio/ui-core/BotCommands.h b/examples/companion_radio/ui-core/BotCommands.h new file mode 100644 index 00000000..6d9018b2 --- /dev/null +++ b/examples/companion_radio/ui-core/BotCommands.h @@ -0,0 +1,99 @@ +#pragma once +// The bot's ! commands as a table: which ones a target (channel, room, direct) +// answers. A target keeps a mask of the ones switched off, so 0 is all of them +// and a command added later starts on. No radio and no prefs here, so it's +// tested on the host (test/test_bot_commands). The commands themselves are in +// MyMeshBot.h, the mask's rows in BotConfig.h. + +#include +#include +#include + +namespace botcmd { + +enum Id : uint8_t { PING, BATT, TEMP, TIME, LOC, HOPS, PATH, SNR, DIST, STATUS, HELP, + GPS, BUZZ, ADVERT, GPIO, COUNT }; + +// name: as !help lists it. hint: a one-line explanation for frontends with room. +struct Cmd { const char* name; const char* label; const char* hint; }; + +static const Cmd CMDS[COUNT] = { + { "ping", "!ping", "Answers pong" }, + { "batt", "!batt", "Battery" }, + { "temp", "!temp", "Temperature" }, + { "time", "!time", "Clock" }, + { "loc", "!loc position", "Off: hidden in !status, !dist, !gps fix too" }, + { "hops", "!hops", "Hops the command took" }, + { "path", "!path", "The route it came by" }, + { "snr", "!snr", "How its last hop was heard" }, + { "dist", "!dist", "How far the sender is, and which way" }, + { "status", "!status", "Battery, position and time" }, + { "help", "!help", "The list of commands" }, + { "gps", "!gps", "Action: on, off, fix" }, + { "buzz", "!buzz", "Action: sounds the buzzer" }, + { "advert", "!advert", "Action: sends an advert" }, + { "gpio1-4", "!gpio1-4", "Action: reads and sets the pins" }, +}; + +static inline uint16_t flag(int id) { return (uint16_t)(1u << id); } + +// How many of the table a target's list shows: the actions, last in it, only +// with its Actions on. Their switches keep their state while hidden. +static inline int count(bool actions) { return actions ? (int)COUNT : (int)GPS; } + +// What `off` switches off, by the word after the "!" (lowercase). -1: not a +// command of the table. +static inline int find(const char* cmd) { + if (!strncmp(cmd, "gpio", 4) && cmd[4] >= '1' && cmd[4] <= '4' && !cmd[5]) return GPIO; + for (int i = 0; i < GPIO; i++) if (!strcmp(cmd, CMDS[i].name)) return i; + return -1; +} + +// Where a message's commands begin, nullptr if it has none: only at its start, +// past a reply's "@[name] ", so text that merely mentions one doesn't run it, +// nor does another bot's !help answer ("cmds: !ping !buzz ..."). +static inline const char* start(const char* body) { + const char* p = body; + while (*p == ' ') p++; + if (p[0] == '@' && p[1] == '[') { + p = strchr(p, ']'); + if (!p) return nullptr; + p++; + while (*p == ' ') p++; + } + return *p == '!' ? p : nullptr; +} + +// The position may leave: !loc is the switch for all of it. Anything else with +// !loc off would give away what the user has just hidden: !dist from any spot +// the sender claims, !gps fix outright. +static inline bool position(uint16_t off) { return !(off & flag(LOC)); } + +// Unknown words count as enabled: they're the caller's to reject. +static inline bool enabled(const char* cmd, uint16_t off) { + int id = find(cmd); + if (id < 0) return true; + if (id == DIST && !position(off)) return false; + return !(off & flag(id)); +} + +// "cmds: !ping !batt ...": only what would answer. Actions only when allowed. +static inline void help(char* out, int n, uint16_t off, bool actions) { + int o = snprintf(out, n, "cmds:"); + for (int i = 0; i < COUNT && o < n; i++) { + if (i == HELP || (i >= GPS && !actions)) continue; + if ((off & flag(i)) || (i == DIST && !position(off))) continue; + o += snprintf(out + o, n - o, " !%s", CMDS[i].name); + } +} + +// A row's value: "All", or how many of the listed ones are on. +static inline void shown(uint16_t off, bool actions, char* out, int n) { + const int total = count(actions); + int on = 0; + for (int i = 0; i < total; i++) if (!(off & flag(i))) on++; + if (on == total) snprintf(out, n, "All"); + else snprintf(out, n, "%d of %d", on, total); +} + +} // namespace botcmd diff --git a/examples/companion_radio/ui-core/BotConfig.h b/examples/companion_radio/ui-core/BotConfig.h index b36f49f1..e095e61d 100644 --- a/examples/companion_radio/ui-core/BotConfig.h +++ b/examples/companion_radio/ui-core/BotConfig.h @@ -6,7 +6,10 @@ // // Row kinds: switches (a uint8_t flag), text (trigger / reply, with a // capacity), a target (channel / room: a picker in the frontend), an hour -// (quiet from / to; equal hours = quiet hours off). +// (quiet from / to; equal hours = quiet hours off), a list of the commands a +// target answers (a mask over botcmd::, a list popup in the frontend). + +#include "BotCommands.h" namespace botcfg { @@ -22,10 +25,10 @@ static const char* const TAB_LABELS[TAB_COUNT] = { "Channel", "Room", "Direct", static bool isLog(int) { return false; } #endif -enum Kind : uint8_t { ENABLE_DM, DM_SCOPE, COMMANDS_DM, ACTIONS_DM, TRIGGER_DM, REPLY_DM, - ENABLE_CH, CHANNEL, COMMANDS_CH, ACTIONS_CH, TRIGGER_CH, REPLY_CH, - ENABLE_ROOM, ROOM, COMMANDS_ROOM, ACTIONS_ROOM, TRIGGER_ROOM, REPLY_ROOM, - QUIET_FROM, QUIET_TO, ACTIONS_SCOPE }; +enum Kind : uint8_t { ENABLE_DM, DM_SCOPE, COMMANDS_DM, ALLOWED_DM, ACTIONS_DM, TRIGGER_DM, REPLY_DM, + ENABLE_CH, CHANNEL, COMMANDS_CH, ALLOWED_CH, ACTIONS_CH, TRIGGER_CH, REPLY_CH, + ENABLE_ROOM, ROOM, COMMANDS_ROOM, ALLOWED_ROOM, ACTIONS_ROOM, TRIGGER_ROOM, REPLY_ROOM, + QUIET_FROM, QUIET_TO, ACTIONS_SCOPE, POS_SCOPE, GAP }; // hint: a one-line explanation for frontends with room for it. struct Row { Kind kind; const char* label; const char* hint; }; @@ -34,6 +37,7 @@ static const Row CHANNEL_ROWS[] = { { ENABLE_CH, "Enable", "Answer on one channel" }, { CHANNEL, "Channel", nullptr }, { COMMANDS_CH, "Commands", "!ping, !help, ..." }, + { ALLOWED_CH, "Allowed", "Which ones it answers" }, { ACTIONS_CH, "Actions", "!gps, !gpio (this device)" }, { TRIGGER_CH, "Trigger", "Reply trigger; * = any" }, { REPLY_CH, "Reply", "{name} {loc} {time} ..." }, @@ -42,6 +46,7 @@ static const Row ROOM_ROWS[] = { { ENABLE_ROOM, "Enable", "Answer in one room server" }, { ROOM, "Room", nullptr }, { COMMANDS_ROOM, "Commands", "!ping, !help, ..." }, + { ALLOWED_ROOM, "Allowed", "Which ones it answers" }, { ACTIONS_ROOM, "Actions", "!gps, !gpio (this device)" }, { TRIGGER_ROOM, "Trigger", "Reply trigger; * = any" }, { REPLY_ROOM, "Reply", "{name} {loc} {time} ..." }, @@ -49,7 +54,9 @@ static const Row ROOM_ROWS[] = { static const Row DIRECT_ROWS[] = { { ENABLE_DM, "Enable", "Answer direct messages" }, { DM_SCOPE, "DM allow", "Everyone, or favourites only" }, + { POS_SCOPE, "Position", "Fav: only favourites get it" }, { COMMANDS_DM, "Commands", "!ping, !help, ..." }, + { ALLOWED_DM, "Allowed", "Which ones it answers" }, { ACTIONS_DM, "Actions", "!gps, !gpio (this device)" }, { TRIGGER_DM, "Trigger", "Reply trigger; * = any" }, { REPLY_DM, "Reply", "{name} {loc} {time} ..." }, @@ -58,6 +65,7 @@ static const Row OTHER_ROWS[] = { { QUIET_FROM, "Quiet from", "No replies in these hours" }, { QUIET_TO, "Quiet to", "Same hour = always on" }, { ACTIONS_SCOPE, "Actions by", "Fav: favourites only (a channel name can be faked)" }, + { GAP, "Reply gap", "On the channel and in the room" }, }; static int rowCount(int tab) { @@ -93,10 +101,26 @@ static uint8_t* flag(NodePrefs* p, Kind k) { case ACTIONS_CH: return &p->bot_actions_ch; case ACTIONS_ROOM: return &p->bot_actions_room; case ACTIONS_SCOPE: return &p->bot_actions_scope; // 1 = favourites only + case POS_SCOPE: return &p->bot_pos_scope; // 1 = favourites only default: return nullptr; } } +// Allowed rows: the target's mask of commands switched off; nullptr for other kinds. +static uint16_t* cmdOff(NodePrefs* p, Kind k) { + switch (k) { + case ALLOWED_DM: return &p->bot_cmd_off_dm; + case ALLOWED_CH: return &p->bot_cmd_off_ch; + case ALLOWED_ROOM: return &p->bot_cmd_off_room; + default: return nullptr; + } +} + +// That Allowed row's target has its Actions on: the list shows them. +static bool actionsOn(const NodePrefs* p, Kind k) { + return (k == ALLOWED_DM ? p->bot_actions_dm : k == ALLOWED_CH ? p->bot_actions_ch : p->bot_actions_room) != 0; +} + // Text rows: the buffer and its size (incl. NUL); nullptr for other kinds. static char* text(NodePrefs* p, Kind k, int& cap) { switch (k) { @@ -111,13 +135,16 @@ static char* text(NodePrefs* p, Kind k, int& cap) { } // All / Fav rows (a flag shown as who, not on / off). -static bool isScope(Kind k) { return k == DM_SCOPE || k == ACTIONS_SCOPE; } +static bool isScope(Kind k) { return k == DM_SCOPE || k == ACTIONS_SCOPE || k == POS_SCOPE; } static bool isTrigger(Kind k) { return k == TRIGGER_DM || k == TRIGGER_CH || k == TRIGGER_ROOM; } static bool isHour(Kind k) { return k == QUIET_FROM || k == QUIET_TO; } static uint8_t& hour(NodePrefs* p, Kind k) { return k == QUIET_FROM ? p->bot_quiet_start : p->bot_quiet_end; } static bool quietOff(const NodePrefs* p) { return p->bot_quiet_start == p->bot_quiet_end; } +// The Reply gap's choices, by NodePrefs::botGapSecs() index; "" ends the list. +static const char* GAP_LABELS[NodePrefs::BOT_GAP_COUNT + 1] = { "10s", "30s", "1m", "5m", "" }; + // Placeholders a reply can use: the bot fills {name} / {hops} / {path} from the // message it answers; {loc} / {time} and the sensor ones come from here. // Most useful first (a frontend short of room shows a prefix). diff --git a/examples/companion_radio/ui-lvgl/BotScreen.h b/examples/companion_radio/ui-lvgl/BotScreen.h index 333e173d..9d76dee1 100644 --- a/examples/companion_radio/ui-lvgl/BotScreen.h +++ b/examples/companion_radio/ui-lvgl/BotScreen.h @@ -3,7 +3,8 @@ // their NodePrefs fields are ui-core/BotConfig.h; the bot runs in MyMesh. // Tabs Channel / Room / Direct / Other; switches inline, trigger / reply in a // popup with the keyboard (reply: placeholder buttons above it), channel / -// room from a list popup, quiet hours with - / +. +// room from a list popup, quiet hours with - / +, the commands a target +// answers as a list of switches in a popup. // // Single-TU fragment: included by ui-lvgl/UITask.cpp after AdminScreen.h. @@ -12,6 +13,7 @@ namespace botview { static uint8_t s_tab = botcfg::TAB_CHANNEL; static int s_row = -1; // row a popup is editing static lv_obj_t* s_hour_lbl = nullptr; +static lv_obj_t* s_allowed_lbl = nullptr; // the Allowed row's value, behind the popup static uint8_t s_rooms[32][NodePrefs::FAVOURITE_PREFIX_LEN]; enum : uint8_t { B_MINUS, B_PLUS, B_DONE }; @@ -30,6 +32,12 @@ static void onBotScope(lv_event_t* e) { s_ui->botToggle((int)(uintptr_t)lv_event_get_user_data(e), lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e)) == 1); } +static void onBotCmd(lv_event_t* e) { + s_ui->botCmd((int)(uintptr_t)lv_event_get_user_data(e), lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)); +} +static void onBotGap(lv_event_t* e) { + s_ui->botGap((int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e))); +} static void onBotPick(lv_event_t* e) { s_ui->botPick((int)(uintptr_t)lv_event_get_user_data(e)); } static void onBotHour(lv_event_t* e) { s_ui->botHour((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } static void onBotTextKb(lv_event_t* e) { s_ui->botTextDone(lv_event_get_code(e) == LV_EVENT_READY); } @@ -50,6 +58,12 @@ static void botChip(const char* ph, void* parent) { lv_obj_add_event_cb(b, onBotPlaceholder, LV_EVENT_CLICKED, NULL); lv_obj_center(label(b, ph, THEME_FONT_SMALL, theme::TEXT)); } +// A switchRow() for something that isn't a plain pref: `cb` gets `idx`. +static void switchRowIdx(lv_obj_t* parent, const char* text, const char* hint, bool on, lv_event_cb_t cb, int idx) { + lv_obj_t* sw = switchRow(parent, text, hint, nullptr); + if (on) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, cb, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)idx); +} static void onOpenBot(lv_event_t* e) { (void)e; s_ui->showBot(); } static lv_obj_t* botHourButton(lv_obj_t* parent, const char* text, uint8_t act, bool accent) { @@ -65,6 +79,18 @@ static lv_obj_t* botHourButton(lv_obj_t* parent, const char* text, uint8_t act, return b; } +// The open tab's Allowed value: it follows that tab's Actions too. +static void botShowAllowed(NodePrefs* p) { + for (int i = 0; i < botcfg::rowCount(botview::s_tab); i++) { + botcfg::Kind k = botcfg::row(botview::s_tab, i).kind; + uint16_t* off = botcfg::cmdOff(p, k); + if (!off || !botview::s_allowed_lbl) continue; + char val[16]; + botcmd::shown(*off, botcfg::actionsOn(p, k), val, sizeof(val)); + lv_label_set_text(botview::s_allowed_lbl, val); + } +} + void UITask::showBot() { _screen = SCR_BOT; buildBot(); @@ -121,37 +147,37 @@ void UITask::buildBot() { for (int i = 0; i < botcfg::rowCount(s_tab); i++) { const botcfg::Row& r = botcfg::row(s_tab, i); uint8_t* fl = botcfg::flag(p, r.kind); - lv_obj_t* row = settingRow(g, r.label, fl || botcfg::isHour(r.kind) ? r.hint : nullptr); - if (fl && botcfg::isScope(r.kind)) { + if (fl && !botcfg::isScope(r.kind)) { + switchRowIdx(g, r.label, r.hint, *fl, onBotSwitch, i); + continue; + } + uint16_t* cmd_off = botcfg::cmdOff(p, r.kind); + const bool gap = r.kind == botcfg::GAP; + lv_obj_t* row = settingRow(g, r.label, fl || cmd_off || gap || botcfg::isHour(r.kind) ? r.hint : nullptr); + if (gap) { + lv_obj_t* seg = segmented(row, botcfg::GAP_LABELS, p->bot_gap_idx, 160, 32); + lv_obj_align(seg, LV_ALIGN_RIGHT_MID, -4, 0); + lv_obj_add_event_cb(seg, onBotGap, LV_EVENT_VALUE_CHANGED, NULL); + continue; + } + if (fl) { static const char* SCOPE[] = { "All", "Fav", "" }; lv_obj_t* seg = segmented(row, SCOPE, *fl ? 1 : 0, 110, 32); lv_obj_align(seg, LV_ALIGN_RIGHT_MID, -4, 0); lv_obj_add_event_cb(seg, onBotScope, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)i); continue; } - if (fl) { - lv_obj_t* sw = lv_switch_create(row); - lv_obj_set_size(sw, 46, 24); - lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -theme::PAD, 0); - if (*fl) lv_obj_add_state(sw, LV_STATE_CHECKED); - lv_obj_add_event_cb(sw, onBotSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)i); - continue; - } // Value on the right; tap the row to change it. char val[48]; int cap; if (const char* t = botcfg::text(p, r.kind, cap)) snprintf(val, sizeof(val), "%s", botcfg::shownText(t, botcfg::isTrigger(r.kind))); else if (r.kind == botcfg::CHANNEL) { if (!botcfg::channelName(p, val, sizeof(val))) snprintf(val, sizeof(val), "(pick one)"); } else if (r.kind == botcfg::ROOM) { if (!botcfg::roomName(p, val, sizeof(val))) snprintf(val, sizeof(val), "(pick one)"); } + else if (cmd_off) botcmd::shown(*cmd_off, botcfg::actionsOn(p, r.kind), val, sizeof(val)); else if (botcfg::quietOff(p)) snprintf(val, sizeof(val), "Off"); else snprintf(val, sizeof(val), "%02d:00", botcfg::hour(p, r.kind)); - lv_obj_t* v = label(row, val, THEME_FONT_BODY, theme::ACCENT); - lv_label_set_long_mode(v, LV_LABEL_LONG_DOT); - lv_obj_set_width(v, botcfg::isHour(r.kind) ? 60 : 150); // fixed: LONG_DOT needs a width - lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_RIGHT, 0); - label(row, LV_SYMBOL_RIGHT, THEME_FONT_SMALL, theme::TEXT_MUTED); - rowPressable(row); - lv_obj_add_event_cb(row, onBotRow, LV_EVENT_CLICKED, (void*)(uintptr_t)i); + lv_obj_t* v = rowPick(row, val, botcfg::isHour(r.kind) ? 60 : cmd_off ? 80 : 150, onBotRow, (void*)(uintptr_t)i); + if (cmd_off) s_allowed_lbl = v; } } @@ -167,6 +193,25 @@ void UITask::botToggle(int row, bool on) { if (!fl) return; *fl = on ? 1 : 0; prefsSave(); + botShowAllowed(_prefs); +} + +void UITask::botGap(int idx) { + if (!_prefs || idx < 0 || idx >= NodePrefs::BOT_GAP_COUNT) return; + _prefs->bot_gap_idx = (uint8_t)idx; + prefsSave(); +} + +// A command's switch in the Allowed popup: the mask is the row's the popup opened on. +void UITask::botCmd(int id, bool on) { + using namespace botview; + if (!_prefs || s_row < 0 || s_row >= botcfg::rowCount(s_tab)) return; + uint16_t* off = botcfg::cmdOff(_prefs, botcfg::row(s_tab, s_row).kind); + if (!off || id < 0 || id >= botcmd::COUNT) return; + if (on) *off &= ~botcmd::flag(id); + else *off |= botcmd::flag(id); + prefsSave(); + botShowAllowed(_prefs); } // Tap on a value row: its editor. @@ -207,6 +252,13 @@ void UITask::botRow(int row) { return; } + if (uint16_t* off = botcfg::cmdOff(p, r.kind)) { + lv_obj_t* g = group(scrollList(navPopupPanel("Allowed commands", true)), nullptr); + for (int i = 0; i < botcmd::count(botcfg::actionsOn(p, r.kind)); i++) + switchRowIdx(g, botcmd::CMDS[i].label, botcmd::CMDS[i].hint, !(*off & botcmd::flag(i)), onBotCmd, i); + return; + } + if (r.kind == botcfg::CHANNEL || r.kind == botcfg::ROOM) { lv_obj_t* panel = navPopupPanel(r.kind == botcfg::CHANNEL ? "Answer on channel" : "Answer in room", true); lv_obj_t* list = lv_obj_create(panel); diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 83de446f..a6bf2b56 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -313,6 +313,8 @@ public: void botTab(int tab); void botRow(int row); void botToggle(int row, bool on); + void botCmd(int id, bool on); + void botGap(int idx); void botPick(int idx); void botHour(uint8_t act); void botTextDone(bool ok); diff --git a/examples/companion_radio/ui-lvgl/Widgets.h b/examples/companion_radio/ui-lvgl/Widgets.h index 025695ec..82fc0b68 100644 --- a/examples/companion_radio/ui-lvgl/Widgets.h +++ b/examples/companion_radio/ui-lvgl/Widgets.h @@ -908,6 +908,19 @@ static lv_obj_t* choiceRow(lv_obj_t* parent, const char* text, const char* hint, return c; } +// A value picked or typed behind the row: cut to `w`, with a chevron, the whole +// row is the tap. Returns the value's label. +static lv_obj_t* rowPick(lv_obj_t* row, const char* text, int w, lv_event_cb_t cb, void* user = nullptr) { + lv_obj_t* v = label(row, text, THEME_FONT_BODY, theme::ACCENT); + lv_label_set_long_mode(v, LV_LABEL_LONG_DOT); + lv_obj_set_width(v, w); // fixed: LONG_DOT needs a width + lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_RIGHT, 0); + label(row, LV_SYMBOL_RIGHT, THEME_FONT_SMALL, theme::TEXT_MUTED); + rowPressable(row); + lv_obj_add_event_cb(row, cb, LV_EVENT_CLICKED, user); + return v; +} + // A value typed in a popup (frequency, name ...): shown as a choice is, with // a pencil; the tap is the caller's. static lv_obj_t* rowValue(lv_obj_t* row, const char* text, lv_event_cb_t cb, void* user = nullptr) { diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index 34d02613..e700f4b4 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -33,6 +33,10 @@ class BotScreen : public UIScreen { // steps the hour and Enter/Cancel exits back to normal row browsing. int _stepper_row; // -1=off, else the row index being stepped + // Allowed rows: the commands list instead of the rows, over this target's mask. + uint16_t* _cmd_off; + int _cmd_sel, _cmd_scroll, _cmd_count; + // channel/room counts (refreshed on enter) — just gate whether Enter opens // the (possibly empty) picker; the picker itself browses the real list, so // no local index cache is needed now that there's no LEFT/RIGHT quick-cycle. @@ -66,6 +70,27 @@ class BotScreen : public UIScreen { tabbar::draw(display, botcfg::TAB_LABELS, botcfg::TAB_COUNT, _tab, right_reserve); } + int renderCommands(DisplayDriver& display) { + drawScreenHeader(display, "Bot commands"); + drawList(display, _cmd_count, _cmd_sel, _cmd_scroll, [&](int i, int y, bool sel, int reserve) { + drawRowSelection(display, y, sel, reserve); + info::listRow(display, y, botcmd::CMDS[i].label, (*_cmd_off & botcmd::flag(i)) ? "OFF" : "ON", sel, reserve); + display.setColor(UIColor::primary_txt); + }); + return 2000; + } + + bool commandsInput(char c) { + if (c == KEY_CANCEL) { _cmd_off = nullptr; return true; } + if (c == KEY_UP) { _cmd_sel = (_cmd_sel + _cmd_count - 1) % _cmd_count; return true; } + if (c == KEY_DOWN) { _cmd_sel = (_cmd_sel + 1) % _cmd_count; return true; } + if (c == KEY_ENTER || keyIsPrev(c) || keyIsNext(c)) { + *_cmd_off ^= botcmd::flag(_cmd_sel); + _dirty = true; + } + return true; + } + public: BotScreen(UITask* task, NodePrefs* prefs, KeyboardWidget* kb) : _task(task), _prefs(prefs), _tab(botcfg::TAB_CHANNEL), _kb(kb) {} @@ -77,6 +102,7 @@ public: _scroll = 0; _kb_row = -1; _stepper_row = -1; + _cmd_off = nullptr; _dirty = false; refreshChannels(); refreshRooms(); @@ -89,6 +115,7 @@ public: if (_kb_row >= 0) { return _kb->render(display); } + if (_cmd_off) return renderCommands(display); // Reply counter, right-aligned in the header — reserve its width from the // tab bar before drawing so a right-side tab can't run under it. @@ -123,6 +150,11 @@ public: shown = botcfg::isScope(r.kind) ? (*fl ? "Fav" : "All") : (*fl ? "ON" : "OFF"); } else if (txt) { shown = botcfg::shownText(txt, botcfg::isTrigger(r.kind)); + } else if (const uint16_t* off = botcfg::cmdOff(_prefs, r.kind)) { + botcmd::shown(*off, botcfg::actionsOn(_prefs, r.kind), name, sizeof(name)); + shown = name; + } else if (r.kind == botcfg::GAP) { + shown = botcfg::GAP_LABELS[_prefs->bot_gap_idx]; } else if (r.kind == botcfg::CHANNEL || r.kind == botcfg::ROOM) { bool any = r.kind == botcfg::CHANNEL ? _num_channels > 0 : _num_rooms > 0; bool ok = r.kind == botcfg::CHANNEL ? botcfg::channelName(_prefs, name, sizeof(name)) @@ -188,6 +220,8 @@ public: return true; } + if (_cmd_off) return commandsInput(c); + if (_stepper_row >= 0) { uint8_t& h = botcfg::hour(_prefs, tabRow(_tab, _stepper_row).kind); if (up) { h = (h + 1) % 24; _dirty = true; return true; } @@ -215,6 +249,12 @@ public: Kind k = tabRow(_tab, _sel).kind; if (uint8_t* fl = botcfg::flag(_prefs, k)) { *fl ^= 1; _dirty = true; return true; } + if (uint16_t* off = botcfg::cmdOff(_prefs, k)) { + _cmd_off = off; + _cmd_count = botcmd::count(botcfg::actionsOn(_prefs, k)); + _cmd_sel = _cmd_scroll = 0; + return true; + } int cap = 0; if (char* txt = botcfg::text(_prefs, k, cap)) { _kb_row = _sel; @@ -252,6 +292,10 @@ public: case botcfg::QUIET_TO: _stepper_row = _sel; return true; + case botcfg::GAP: + _prefs->bot_gap_idx = (_prefs->bot_gap_idx + 1) % NodePrefs::BOT_GAP_COUNT; + _dirty = true; + return true; default: return false; } } diff --git a/test/test_bot_commands/test_bot_commands.cpp b/test/test_bot_commands/test_bot_commands.cpp new file mode 100644 index 00000000..209aa157 --- /dev/null +++ b/test/test_bot_commands/test_bot_commands.cpp @@ -0,0 +1,118 @@ +#include + +#include + +// Which ! commands a target answers (ui-core/BotCommands.h). +#include "../../examples/companion_radio/ui-core/BotCommands.h" + +using namespace botcmd; + +TEST(BotCommands, CommandsOnlyAtTheStart) { + EXPECT_STREQ(start("!batt !time"), "!batt !time"); + EXPECT_STREQ(start(" !batt"), "!batt"); + EXPECT_EQ(start("don't write !loc here"), nullptr); + EXPECT_EQ(start("cmds: !ping !buzz !advert"), nullptr); // another bot's !help + EXPECT_EQ(start("hello"), nullptr); + EXPECT_EQ(start(""), nullptr); +} + +TEST(BotCommands, PastAReplyPrefix) { + EXPECT_STREQ(start("@[Bob] !batt"), "!batt"); + EXPECT_STREQ(start("@[Bob]!batt"), "!batt"); + EXPECT_EQ(start("@[Bob] thanks !batt"), nullptr); + EXPECT_EQ(start("@[Bob !batt"), nullptr); +} + +TEST(BotCommands, EverythingOnByDefault) { + for (int i = 0; i < COUNT; i++) EXPECT_TRUE(enabled(i == GPIO ? "gpio1" : CMDS[i].name, 0)) << CMDS[i].name; +} + +TEST(BotCommands, FindsTheTableNames) { + EXPECT_EQ(find("ping"), PING); + EXPECT_EQ(find("status"), STATUS); + EXPECT_EQ(find("advert"), ADVERT); +} + +TEST(BotCommands, TheFourPinsAreOneSwitch) { + for (const char* c : { "gpio1", "gpio2", "gpio3", "gpio4" }) EXPECT_EQ(find(c), GPIO); + EXPECT_EQ(find("gpio5"), -1); + EXPECT_EQ(find("gpio12"), -1); + EXPECT_EQ(find("gpio1-4"), -1); // the row's name, not something to type + EXPECT_FALSE(enabled("gpio3", flag(GPIO))); +} + +TEST(BotCommands, UnknownWordsAreNotBlocked) { + EXPECT_EQ(find("hello"), -1); + EXPECT_TRUE(enabled("hello", 0xFFFF)); +} + +TEST(BotCommands, OneOffLeavesTheOthers) { + EXPECT_FALSE(enabled("batt", flag(BATT))); + EXPECT_TRUE(enabled("ping", flag(BATT))); + EXPECT_TRUE(enabled("temp", flag(BATT))); +} + +TEST(BotCommands, LocOffHidesThePositionEverywhere) { + const uint16_t off = flag(LOC); + EXPECT_FALSE(position(off)); + EXPECT_FALSE(enabled("loc", off)); + EXPECT_FALSE(enabled("dist", off)); + EXPECT_TRUE(enabled("status", off)); // answers, without the position + EXPECT_TRUE(enabled("gps", off)); // on / off still work, the fix doesn't +} + +TEST(BotCommands, DistOffKeepsLoc) { + EXPECT_TRUE(position(flag(DIST))); + EXPECT_TRUE(enabled("loc", flag(DIST))); + EXPECT_FALSE(enabled("dist", flag(DIST))); +} + +TEST(BotCommands, HelpListsAllWithoutItself) { + char b[128]; + help(b, sizeof(b), 0, true); + EXPECT_STREQ(b, "cmds: !ping !batt !temp !time !loc !hops !path !snr !dist !status !gps !buzz !advert !gpio1-4"); +} + +TEST(BotCommands, HelpLeavesOutActionsWhenNotAllowed) { + char b[128]; + help(b, sizeof(b), 0, false); + EXPECT_STREQ(b, "cmds: !ping !batt !temp !time !loc !hops !path !snr !dist !status"); +} + +TEST(BotCommands, HelpLeavesOutWhatIsOff) { + char b[128]; + help(b, sizeof(b), flag(LOC) | flag(BATT) | flag(GPS), true); + EXPECT_STREQ(b, "cmds: !ping !temp !time !hops !path !snr !status !buzz !advert !gpio1-4"); +} + +TEST(BotCommands, HelpStopsAtTheBuffer) { + char b[12]; + memset(b, 'x', sizeof(b)); + help(b, sizeof(b), 0, true); + EXPECT_LT(strlen(b), sizeof(b)); +} + +TEST(BotCommands, RowValue) { + char b[16]; + shown(0, true, b, sizeof(b)); + EXPECT_STREQ(b, "All"); + shown(flag(LOC) | flag(DIST), true, b, sizeof(b)); + EXPECT_STREQ(b, "13 of 15"); + shown(0xFFFF, true, b, sizeof(b)); + EXPECT_STREQ(b, "0 of 15"); +} + +TEST(BotCommands, RowValueWithoutActionsCountsWhatIsListed) { + char b[16]; + EXPECT_EQ(count(false), (int)GPS); + EXPECT_EQ(count(true), (int)COUNT); + shown(flag(GPS) | flag(GPIO), false, b, sizeof(b)); // hidden: not counted + EXPECT_STREQ(b, "All"); + shown(flag(LOC) | flag(GPS), false, b, sizeof(b)); + EXPECT_STREQ(b, "10 of 11"); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}