feat(bot): {path} !path (#55), {snr} {rssi} !snr, !dist, Actions by All/Fav

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-26 19:46:28 +01:00
co-authored by Claude Opus 5.5
parent 81c551425e
commit 30ebef765f
9 changed files with 127 additions and 18 deletions
+7 -2
View File
@@ -41,7 +41,7 @@ channel and one room, each switched on separately:
- **Trigger and reply**: when a message contains the trigger (several can be
separated by commas, `*` matches any message), the bot sends the reply. The
reply can use placeholders, plus `{name}` (the sender) and `{hops}`.
reply can use placeholders, plus `{name}` (the sender), `{hops}`, `{path}` (the route in, as hop hashes oldest first: `3f:a1:c8`, or `direct`), `{snr}` and `{rssi}` (how its last hop was heard).
- **Commands**: messages starting with `!` get live data back:
| Command | Reply |
@@ -49,13 +49,18 @@ channel and one room, each switched on separately:
| `!ping` | `pong` |
| `!batt`, `!temp`, `!time`, `!loc` | battery, temperature, time, position |
| `!hops` | how many hops the command took |
| `!path` | which way it came: the hop hashes, oldest first (`3f:a1:c8`; 2-byte mode `3fa1:c802`), or `direct` |
| `!snr` | how its last hop was heard here: SNR and RSSI |
| `!dist` | how far away and which way the sender is (both need a position) |
| `!status` | battery, position and time |
| `!help` | the list of commands |
Several in one message get one reply: `!batt !time` → `4.10V | 14:30`.
- **Actions** (off by default) let the command change the device: `!buzz`
(sounds the buzzer to find it), `!gps on` / `!gps off`, `!gps fix` (turns
the GPS on, waits for a good fix and sends it), `!advert`.
the GPS on, waits for a good fix and sends it), `!advert`. **Actions by =
Fav** (Other tab) lets only your favourites run them; on a channel the
sender is just a name, so there it's a weak check.
**DM allow = Fav** limits the DM bot to your favourites, and **quiet hours**
stop trigger replies at night (commands still answer). Replies are rate
+23 -1
View File
@@ -21,6 +21,15 @@
// {co2} — CO2 concentration ppm (requires sm)
// {name} — sender's name (bot replies only; requires sender_name, else left literal)
// {hops} — hop count: "direct" or "N hops" (bot replies only; requires hops>=0, else left literal)
// {path} — inbound route as hop hashes, oldest first: "3f:a1:c8" (bot replies only; requires `in`, else left literal)
// {snr} — the message's last hop's SNR, "6.5dB" (bot replies only)
// {rssi} — and its RSSI, "-92dBm" (bot replies only)
// How the message a bot reply answers came in (MyMesh::setBotInbound()).
struct BotInbound {
const char* path; // "direct" or the hop hashes
float snr, rssi; // the last hop's
};
inline void expandMsg(const char* tmpl, char* out, int out_len,
double lat, double lon, bool gps_valid,
uint32_t utc_ts, int8_t tz_hours,
@@ -28,7 +37,8 @@ inline void expandMsg(const char* tmpl, char* out, int out_len,
float batt_volts = -1.0f,
const char* sender_name = nullptr,
int hops = -1,
float mcu_temp = NAN) { // the chip's, for {temp} without a sensor
float mcu_temp = NAN,
const BotInbound* in = nullptr) { // the chip's, for {temp} without a sensor
// sv indices: 0=temp 1=hum 2=pres 3=batt 4=alt 5=lux 6=dist 7=co2
float sv[8] = {};
bool sv_ok[8] = {};
@@ -139,6 +149,18 @@ inline void expandMsg(const char* tmpl, char* out, int out_len,
APPEND(b, strlen(b));
} else { APPEND("{hops}", 6); }
p += 6;
} else if (strncmp(p, "{path}", 6) == 0) {
if (in && in->path) { APPEND(in->path, strlen(in->path)); }
else { APPEND("{path}", 6); }
p += 6;
} else if (strncmp(p, "{snr}", 5) == 0) {
if (in) { char b[12]; snprintf(b, sizeof(b), "%.1fdB", in->snr); APPEND(b, strlen(b)); }
else { APPEND("{snr}", 5); }
p += 5;
} else if (strncmp(p, "{rssi}", 6) == 0) {
if (in) { char b[12]; snprintf(b, sizeof(b), "%.0fdBm", in->rssi); APPEND(b, strlen(b)); }
else { APPEND("{rssi}", 6); }
p += 6;
} else {
out[oi++] = *p++;
}
+9
View File
@@ -694,6 +694,7 @@ void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t
// retransmit priority. 0 = heard directly. (Raw path_len is a size/count
// bitfield for transport packets, so it must not be used directly.)
uint8_t hops = pkt ? pkt->getPathHashCount() : 0;
setBotInbound(pkt, &from);
if (!tryBotCommand(from, text, hops)) // commands take priority; fall through to trigger reply
tryBotReplyDM(from, text, hops);
}
@@ -753,6 +754,7 @@ void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uin
bool replayed = now > sender_timestamp && now - sender_timestamp > BOT_ROOM_MAX_AGE_SECS;
if (from.type == ADV_TYPE_ROOM && !replayed) {
uint8_t hops = pkt ? pkt->getPathHashCount() : 0;
setBotInbound(pkt, sender_prefix ? lookupContactByPubKey(sender_prefix, 4) : nullptr);
if (!tryBotRoomCommand(from, sender_prefix, text, hops)) // commands take priority
tryBotReplyRoom(from, sender_prefix, text, hops);
}
@@ -823,6 +825,12 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
// hop count for !hops (see onMessageRecv); not the wire path_len above.
uint8_t ch_hops = pkt->getPathHashCount();
{
char sender[32];
const char* msg;
botChannelSenderSplit(text, sender, sizeof(sender), &msg);
setBotInbound(pkt, botContactByName(sender));
}
if (!tryBotChannelCommand(channel_idx, text, ch_hops)) // commands take priority
tryBotReplyChannel(channel_idx, text, ch_hops);
}
@@ -1416,6 +1424,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
_prefs.bot_commands_enabled = 0;
_prefs.bot_quiet_start = 0;
_prefs.bot_quiet_end = 0; // start==end → quiet hours disabled
_prefs.bot_actions_scope = 0; // actions by everyone
_prefs.dm_show_all = 1; // show all contacts by default
_prefs.fav_sort = 1; // favourites first in lists
_prefs.msg_wake = 1; // a new message turns the screen on
+3
View File
@@ -594,6 +594,9 @@ public:
bool isGpsFixPending() const { return _loc_fix.active; }
private:
void setBotInbound(const mesh::Packet* pkt, const ContactInfo* sender); // for {path} {snr} !path !snr !dist
const ContactInfo* botContactByName(const char* name);
bool botActionsFrom(const ContactInfo* c) const; // bot_actions_scope
void tryBotReplyDM(const ContactInfo& from, const char* text, uint8_t hops);
void tryBotReplyChannel(uint8_t channel_idx, const char* text, uint8_t hops);
void tryBotReplyRoom(const ContactInfo& from, const uint8_t* sender_prefix, const char* text, uint8_t hops);
+71 -9
View File
@@ -128,6 +128,46 @@ bool MyMesh::botInQuietHours() const {
return hourInWindow(h, _prefs.bot_quiet_start, _prefs.bot_quiet_end);
}
// How the message about to be answered came in, for {path} / {snr} / {rssi}
// and !path / !snr / !dist. The route: hop hashes oldest first, ':' between
// hops, each hash as wide as the packet's own path hash mode ("3f:a1:c8",
// "3fa1:c802"); "direct" if heard directly. The sender's position: its
// contact's, when it has one (on a channel, the contact of that name).
static char s_bot_path[MAX_PATH_SIZE * 3 + 8];
static BotInbound s_bot_in = { s_bot_path, 0, 0 };
static bool s_bot_from_pos = false;
static int32_t s_bot_from_lat = 0, s_bot_from_lon = 0;
void MyMesh::setBotInbound(const mesh::Packet* pkt, const ContactInfo* sender) {
const uint8_t n = pkt ? pkt->getPathHashCount() : 0, w = pkt ? pkt->getPathHashSize() : 1;
s_bot_in.snr = pkt ? pkt->getSNR() : 0;
s_bot_in.rssi = _radio->getLastRSSI();
s_bot_from_pos = sender && (sender->gps_lat || sender->gps_lon);
if (s_bot_from_pos) { s_bot_from_lat = sender->gps_lat; s_bot_from_lon = sender->gps_lon; }
if (n == 0) { strcpy(s_bot_path, "direct"); return; }
char* o = s_bot_path;
for (uint8_t h = 0; h < n; h++) {
if (h) *o++ = ':';
for (uint8_t b = 0; b < w; b++) { sprintf(o, "%02x", pkt->path[h * w + b]); o += 2; }
}
}
// The contact named `name` (a channel sender: only a name, unverified).
const ContactInfo* MyMesh::botContactByName(const char* name) {
for (int idx = 0; ; idx++) {
ContactInfo c;
if (!getContactByIdx(idx, c)) return nullptr;
if (c.name[0] && strcmp(c.name, name) == 0) return lookupContactByPubKey(c.id.pub_key, PUB_KEY_SIZE);
}
}
// Actions by (bot_actions_scope): everyone, or favourites only. On a channel
// the sender is only a name anyone can type, so there it's a weak check.
bool MyMesh::botActionsFrom(const ContactInfo* c) const {
if (_prefs.bot_actions_scope == 0) return true;
return c && (c->flags & 0x01);
}
void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text, uint8_t hops) {
if (from.type != ADV_TYPE_CHAT) return;
if (!(_prefs.bot_enabled && _prefs.bot_reply_dm[0])) return;
@@ -143,7 +183,7 @@ void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text, uint8_t ho
sensors.node_lat != 0.0 || sensors.node_lon != 0.0,
ts, _prefs.tz_offset_hours,
&sensors, (float)board.getBattMilliVolts() / 1000.0f,
from.name, hops, board.getMCUTemperature());
from.name, hops, board.getMCUTemperature(), &s_bot_in);
uint32_t expected_ack, est_timeout;
if (sendMessage(from, ts, 0, expanded, expected_ack, est_timeout) != MSG_SEND_FAILED) {
botDmRecord(from.id.pub_key);
@@ -184,7 +224,7 @@ void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text, uint8_t h
sensors.node_lat != 0.0 || sensors.node_lon != 0.0,
ts, _prefs.tz_offset_hours,
&sensors, (float)board.getBattMilliVolts() / 1000.0f,
sender_name, hops, board.getMCUTemperature());
sender_name, hops, board.getMCUTemperature(), &s_bot_in);
// Anti-loop: don't echo a message identical to our own reply (e.g. another bot
// on the channel sending the same text), which would ping-pong forever. The
// per-channel cooldown caps any residual back-and-forth between mismatched bots.
@@ -228,7 +268,7 @@ void MyMesh::tryBotReplyRoom(const ContactInfo& from, const uint8_t* sender_pref
sensors.node_lat != 0.0 || sensors.node_lon != 0.0,
ts, _prefs.tz_offset_hours,
&sensors, (float)board.getBattMilliVolts() / 1000.0f,
sender_name, hops, board.getMCUTemperature());
sender_name, hops, board.getMCUTemperature(), &s_bot_in);
// Anti-loop, mirrors the channel path: don't echo a message identical to
// our own reply (e.g. re-synced back from the room server).
if (strcmp(text, expanded) == 0) return;
@@ -251,6 +291,27 @@ void MyMesh::tryBotReplyRoom(const ContactInfo& from, const uint8_t* sender_pref
// 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) {
if (!strcmp(cmd, "path")) {
strncpy(out, s_bot_path, out_len); // "direct" or the hop hashes, oldest first
out[out_len - 1] = '\0';
return true;
}
if (!strcmp(cmd, "snr")) { // how the last hop was heard here
snprintf(out, out_len, "SNR %.1fdB RSSI %.0fdBm", s_bot_in.snr, s_bot_in.rssi);
return true;
}
if (!strcmp(cmd, "dist")) { // from here to the sender, when both are known
int32_t lat, lon;
LocationProvider* lp = sensors.getLocationProvider();
if (lp && lp->isValid()) { lat = lp->getLatitude(); lon = lp->getLongitude(); }
else { lat = (int32_t)lround(sensors.node_lat * 1e6); lon = (int32_t)lround(sensors.node_lon * 1e6); }
if (!lat && !lon) { snprintf(out, out_len, "dist: no position here"); return true; }
if (!s_bot_from_pos) { snprintf(out, out_len, "dist: no position for you"); return true; }
char d[16];
geo::fmtDist(d, sizeof(d), geo::haversineKm(lat, lon, s_bot_from_lat, s_bot_from_lon), _prefs.units_imperial);
snprintf(out, out_len, "%s %s", d, geo::bearingCardinal(geo::bearingDeg(lat, lon, s_bot_from_lat, s_bot_from_lon)));
return true;
}
if (!strcmp(cmd, "hops")) {
if (hops == 0) strncpy(out, "direct", out_len); // heard directly (0 repeaters)
else snprintf(out, out_len, "%u hops", (unsigned)hops);
@@ -358,8 +419,8 @@ bool MyMesh::botCommandReply(const char* cmd, const char* arg, const char* arg2,
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 !status !gps !buzz !advert !gpio1-4"
: "cmds: !ping !batt !loc !time !temp !hops !status";
? "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 return false; // unknown — let the trigger bot try
expandMsg(tmpl, out, out_len,
@@ -367,7 +428,7 @@ bool MyMesh::botCommandReply(const char* cmd, const char* arg, const char* arg2,
sensors.node_lat != 0.0 || sensors.node_lon != 0.0,
ts, _prefs.tz_offset_hours,
&sensors, (float)board.getBattMilliVolts() / 1000.0f,
sender_name, hops, board.getMCUTemperature());
sender_name, hops, board.getMCUTemperature(), &s_bot_in);
if (strchr(out, '{')) strcpy(out, "n/a"); // requested sensor not present
return true;
}
@@ -444,7 +505,7 @@ bool MyMesh::tryBotCommand(const ContactInfo& from, const char* text, uint8_t ho
uint32_t ts = getRTCClock()->getCurrentTime();
char out[BOT_SCRATCH];
if (botScanCommands(text, hops, ts, out, sizeof(out), from.name, _prefs.bot_actions_dm != 0) == 0) return false; // no commands
if (botScanCommands(text, hops, ts, out, sizeof(out), from.name, _prefs.bot_actions_dm != 0 && botActionsFrom(&from)) == 0) return false; // no commands
if (!botDmAllowed(from.id.pub_key)) { resetPendingBotActions(); return true; } // throttled
uint32_t expected_ack, est_timeout;
@@ -476,7 +537,7 @@ 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) == 0) return false; // no commands
if (botScanCommands(msg, hops, ts, out, sizeof(out), sender_name, _prefs.bot_actions_ch != 0 && botActionsFrom(botContactByName(sender_name))) == 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
@@ -510,7 +571,8 @@ 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) == 0) return false; // no commands
if (botScanCommands(text, hops, ts, out, sizeof(out), sender_name, _prefs.bot_actions_room != 0 &&
botActionsFrom(sender_prefix ? lookupContactByPubKey(sender_prefix, 4) : nullptr)) == 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
+2 -1
View File
@@ -404,6 +404,7 @@ struct NodePrefsFields {
uint8_t bot_actions_room;
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
// ── GPS, trail & location sharing ─────────────────────────────────────
uint8_t gps_enabled; // GPS enabled flag (0=disabled, 1=enabled)
@@ -858,7 +859,7 @@ class NodePrefs : public NodePrefsFields, public ConfigSerializer {
s.def("rm_trig", bot_trigger_room, sizeof(bot_trigger_room));
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("q_start", bot_quiet_start); s.def("q_end", bot_quiet_end); s.def("act_scope", bot_actions_scope);
}
void gpsSolo(Section& s) {
s.def("imperial", units_imperial);
+7 -3
View File
@@ -16,7 +16,7 @@ static const char* const TAB_LABELS[TAB_COUNT] = { "Channel", "Room", "Direct",
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 };
QUIET_FROM, QUIET_TO, ACTIONS_SCOPE };
// hint: a one-line explanation for frontends with room for it.
struct Row { Kind kind; const char* label; const char* hint; };
@@ -48,6 +48,7 @@ static const Row DIRECT_ROWS[] = {
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", "Everyone, or favourites only" },
};
static int rowCount(int tab) {
@@ -81,6 +82,7 @@ static uint8_t* flag(NodePrefs* p, Kind k) {
case ACTIONS_DM: return &p->bot_actions_dm;
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
default: return nullptr;
}
}
@@ -98,16 +100,18 @@ 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 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; }
// Placeholders a reply can use: the bot fills {name} / {hops} from the
// 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).
static const char* const REPLY_PLACEHOLDERS[] = { "{name}", "{hops}", "{loc}", "{time}", "{batt}" };
static const char* const REPLY_PLACEHOLDERS[] = { "{name}", "{hops}", "{path}", "{snr}", "{loc}", "{time}", "{batt}" };
static const int REPLY_PLACEHOLDER_COUNT = sizeof(REPLY_PLACEHOLDERS) / sizeof(REPLY_PLACEHOLDERS[0]);
// Shown value of a text row ("(none)", "(any msg)" for the "*" trigger).
+1 -1
View File
@@ -97,7 +97,7 @@ void UITask::buildBot() {
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 && r.kind == botcfg::DM_SCOPE) {
if (fl && botcfg::isScope(r.kind)) {
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);
+4 -1
View File
@@ -118,7 +118,7 @@ public:
char name[32];
const char* shown = "";
if (fl) {
shown = r.kind == botcfg::DM_SCOPE ? (*fl ? "Fav" : "All") : (*fl ? "ON" : "OFF");
shown = botcfg::isScope(r.kind) ? (*fl ? "Fav" : "All") : (*fl ? "ON" : "OFF");
} else if (txt) {
shown = botcfg::shownText(txt, botcfg::isTrigger(r.kind));
} else if (r.kind == botcfg::CHANNEL || r.kind == botcfg::ROOM) {
@@ -203,6 +203,9 @@ public:
// compose keyboard's own placeholder list.
_kb->addPlaceholder("{name}");
_kb->addPlaceholder("{hops}");
_kb->addPlaceholder("{path}");
_kb->addPlaceholder("{snr}");
_kb->addPlaceholder("{rssi}");
}
return true;
}