mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-11-02 10:51:57 +00:00
feat(bot): Allowed commands per channel, room and direct, Position = Fav, Reply gap, commands only at the start of a message
!loc off is the switch for the position: !status answers without it, !dist is off and !gps fix answers "GPS: fix off". !help is built from the table. ui-core/BotCommands.h, tested on the host. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "MyMesh.h"
|
||||
#include "MsgExpand.h"
|
||||
#include "ui-core/BotCommands.h"
|
||||
#include "GeoUtils.h"
|
||||
#include "Features.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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
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
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
// 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();
|
||||
}
|
||||
Reference in New Issue
Block a user