feat(bot): a Log tab with the bot's last 32 replies (ESP32 and the L2 sim)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-26 19:59:19 +01:00
co-authored by Claude Opus 5.5
parent be03297e41
commit 8aec54b7e5
8 changed files with 151 additions and 3 deletions
+3
View File
@@ -62,6 +62,9 @@ channel and one room, each switched on separately:
Fav** (Other tab) lets only your favourites run them; on a channel the 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. sender is just a name, so there it's a weak check.
On ESP32 boards (L2, Heltec, ...) the **Log** tab lists the bot's last 32
replies: when, who, what they asked and what it answered. Kept until a restart.
**DM allow = Fav** limits the DM bot to your favourites, and **quiet hours** **DM allow = Fav** limits the DM bot to your favourites, and **quiet hours**
stop trigger replies at night (commands still answer). Replies are rate stop trigger replies at night (commands still answer). Replies are rate
limited so two bots can't answer each other forever. The room bot uses the limited so two bots can't answer each other forever. The room bot uses the
+63
View File
@@ -0,0 +1,63 @@
#pragma once
// The bot's recent replies -- who asked what, what it answered, when --
// newest first, for the Bot screen's Log tab. ESP32 only (BOT_LOG): the
// nRF52's heap is short -- and the L2's simulator (BOT_LOG_SIM), which
// stands for one. The ring goes in PSRAM where the board has it, allocated on
// the first reply.
#include <string.h>
#include <stdlib.h>
#if defined(ESP32) || defined(BOT_LOG_SIM)
#define BOT_LOG 1
#endif
#if defined(ESP32)
#include <esp_heap_caps.h>
#endif
struct BotLogEntry {
uint32_t ts; // RTC time of the reply
uint8_t where; // BotLog::DM / CHANNEL / ROOM
char who[24]; // who asked
char req[48]; // what they wrote (cut)
char reply[80]; // what the bot sent (cut)
};
class BotLog {
public:
enum : uint8_t { DM, CHANNEL, ROOM };
#ifdef BOT_LOG
static const int SIZE = 32;
void add(uint32_t ts, uint8_t where, const char* who, const char* req, const char* reply) {
if (!_ring) {
#if defined(ESP32)
_ring = (BotLogEntry*)heap_caps_calloc(SIZE, sizeof(BotLogEntry), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
#endif
if (!_ring) _ring = (BotLogEntry*)calloc(SIZE, sizeof(BotLogEntry));
if (!_ring) return;
}
BotLogEntry& e = _ring[_head];
e.ts = ts;
e.where = where;
copy(e.who, sizeof(e.who), who);
copy(e.req, sizeof(e.req), req);
copy(e.reply, sizeof(e.reply), reply);
_head = (_head + 1) % SIZE;
if (_n < SIZE) _n++;
}
int count() const { return _n; }
const BotLogEntry& at(int i) const { return _ring[(_head + SIZE - 1 - i) % SIZE]; } // 0: the newest
private:
static void copy(char* out, size_t n, const char* s) {
strncpy(out, s ? s : "", n - 1);
out[n - 1] = '\0';
}
BotLogEntry* _ring = nullptr;
int _head = 0, _n = 0;
#else
void add(uint32_t, uint8_t, const char*, const char*, const char*) {}
int count() const { return 0; }
const BotLogEntry& at(int) const { static BotLogEntry e = {}; return e; }
#endif
};
+3
View File
@@ -43,6 +43,7 @@ class UITask;
#include "DataStore.h" #include "DataStore.h"
#include "NodePrefs.h" #include "NodePrefs.h"
#include "ScopeList.h" #include "ScopeList.h"
#include "BotLog.h"
#include <RTClib.h> #include <RTClib.h>
#include <helpers/ArduinoHelpers.h> #include <helpers/ArduinoHelpers.h>
@@ -587,6 +588,7 @@ public:
// Number of auto-replies sent since boot (DM + channel + room). Shown on BotScreen. // Number of auto-replies sent since boot (DM + channel + room). Shown on BotScreen.
uint16_t botReplyCount() const { return _bot_reply_count; } uint16_t botReplyCount() const { return _bot_reply_count; }
const BotLog& botLog() const { return _bot_log; } // the recent replies (ESP32)
// Whether a "!gps fix" request is acquiring/averaging right now -- used by // Whether a "!gps fix" request is acquiring/averaging right now -- used by
// UITask's GPS duty-cycle "is anything live using GPS right now" check, so // UITask's GPS duty-cycle "is anything live using GPS right now" check, so
@@ -726,6 +728,7 @@ private:
static const int BOT_DM_LOG_SIZE = 8; static const int BOT_DM_LOG_SIZE = 8;
BotReplyLog _bot_dm_log[BOT_DM_LOG_SIZE]; BotReplyLog _bot_dm_log[BOT_DM_LOG_SIZE];
uint16_t _bot_reply_count; // total auto-replies sent since boot uint16_t _bot_reply_count; // total auto-replies sent since boot
BotLog _bot_log;
// !gps fix state -- one global slot (one physical GPS): botCommandReply() // !gps fix state -- one global slot (one physical GPS): botCommandReply()
// rejects a second request outright while one is active, so this never // rejects a second request outright while one is active, so this never
+8
View File
@@ -188,6 +188,7 @@ void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text, uint8_t ho
if (sendMessage(from, ts, 0, expanded, expected_ack, est_timeout) != MSG_SEND_FAILED) { if (sendMessage(from, ts, 0, expanded, expected_ack, est_timeout) != MSG_SEND_FAILED) {
botDmRecord(from.id.pub_key); botDmRecord(from.id.pub_key);
_bot_reply_count++; _bot_reply_count++;
_bot_log.add(ts, BotLog::DM, from.name, text, expanded);
#ifdef DISPLAY_CLASS #ifdef DISPLAY_CLASS
if (_listener) _listener->addDMMsg(from.id.pub_key, true, expanded); if (_listener) _listener->addDMMsg(from.id.pub_key, true, expanded);
#endif #endif
@@ -233,6 +234,7 @@ void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text, uint8_t h
if (sendGroupMessage(ts, ch.channel, _prefs.node_name, expanded, rlen)) { if (sendGroupMessage(ts, ch.channel, _prefs.node_name, expanded, rlen)) {
_bot_last_ch_reply_ms = millis(); _bot_last_ch_reply_ms = millis();
_bot_reply_count++; _bot_reply_count++;
_bot_log.add(ts, BotLog::CHANNEL, sender_name, msg, expanded);
#ifdef DISPLAY_CLASS #ifdef DISPLAY_CLASS
mirrorOwnChannelMsg(channel_idx, expanded); mirrorOwnChannelMsg(channel_idx, expanded);
#endif #endif
@@ -277,6 +279,7 @@ void MyMesh::tryBotReplyRoom(const ContactInfo& from, const uint8_t* sender_pref
if (sendMessage(from, ts, 0, expanded, expected_ack, est_timeout) != MSG_SEND_FAILED) { if (sendMessage(from, ts, 0, expanded, expected_ack, est_timeout) != MSG_SEND_FAILED) {
_bot_last_room_reply_ms = millis(); _bot_last_room_reply_ms = millis();
_bot_reply_count++; _bot_reply_count++;
_bot_log.add(ts, BotLog::ROOM, sender_name, text, expanded);
#ifdef DISPLAY_CLASS #ifdef DISPLAY_CLASS
if (_listener) _listener->addDMMsg(from.id.pub_key, true, expanded); if (_listener) _listener->addDMMsg(from.id.pub_key, true, expanded);
#endif #endif
@@ -512,6 +515,7 @@ bool MyMesh::tryBotCommand(const ContactInfo& from, const char* text, uint8_t ho
if (sendMessage(from, ts, 0, out, expected_ack, est_timeout) != MSG_SEND_FAILED) { if (sendMessage(from, ts, 0, out, expected_ack, est_timeout) != MSG_SEND_FAILED) {
botDmRecord(from.id.pub_key); botDmRecord(from.id.pub_key);
_bot_reply_count++; _bot_reply_count++;
_bot_log.add(ts, BotLog::DM, from.name, text, out);
#ifdef DISPLAY_CLASS #ifdef DISPLAY_CLASS
if (_listener) _listener->addDMMsg(from.id.pub_key, true, out); if (_listener) _listener->addDMMsg(from.id.pub_key, true, out);
#endif #endif
@@ -547,6 +551,7 @@ bool MyMesh::tryBotChannelCommand(uint8_t channel_idx, const char* text, uint8_t
if (sendGroupMessage(ts, ch.channel, _prefs.node_name, out, rlen)) { if (sendGroupMessage(ts, ch.channel, _prefs.node_name, out, rlen)) {
_bot_last_ch_reply_ms = millis(); _bot_last_ch_reply_ms = millis();
_bot_reply_count++; _bot_reply_count++;
_bot_log.add(ts, BotLog::CHANNEL, sender_name, msg, out);
#ifdef DISPLAY_CLASS #ifdef DISPLAY_CLASS
mirrorOwnChannelMsg(channel_idx, out); mirrorOwnChannelMsg(channel_idx, out);
#endif #endif
@@ -580,6 +585,7 @@ bool MyMesh::tryBotRoomCommand(const ContactInfo& from, const uint8_t* sender_pr
if (sendMessage(from, ts, 0, out, expected_ack, est_timeout) != MSG_SEND_FAILED) { if (sendMessage(from, ts, 0, out, expected_ack, est_timeout) != MSG_SEND_FAILED) {
_bot_last_room_reply_ms = millis(); _bot_last_room_reply_ms = millis();
_bot_reply_count++; _bot_reply_count++;
_bot_log.add(ts, BotLog::ROOM, sender_name, text, out);
#ifdef DISPLAY_CLASS #ifdef DISPLAY_CLASS
if (_listener) _listener->addDMMsg(from.id.pub_key, true, out); if (_listener) _listener->addDMMsg(from.id.pub_key, true, out);
#endif #endif
@@ -703,6 +709,7 @@ void MyMesh::sendLocFixResult(const char* msg) {
if (sendGroupMessage(ts, ch.channel, _prefs.node_name, msg, mlen)) { if (sendGroupMessage(ts, ch.channel, _prefs.node_name, msg, mlen)) {
_bot_last_ch_reply_ms = millis(); _bot_last_ch_reply_ms = millis();
_bot_reply_count++; _bot_reply_count++;
_bot_log.add(ts, BotLog::CHANNEL, "", "!gps fix", msg);
#ifdef DISPLAY_CLASS #ifdef DISPLAY_CLASS
mirrorOwnChannelMsg(_loc_fix.channel_idx, msg); mirrorOwnChannelMsg(_loc_fix.channel_idx, msg);
#endif #endif
@@ -715,6 +722,7 @@ void MyMesh::sendLocFixResult(const char* msg) {
if (c->type == ADV_TYPE_ROOM) _bot_last_room_reply_ms = millis(); if (c->type == ADV_TYPE_ROOM) _bot_last_room_reply_ms = millis();
else botDmRecord(c->id.pub_key); else botDmRecord(c->id.pub_key);
_bot_reply_count++; _bot_reply_count++;
_bot_log.add(ts, c->type == ADV_TYPE_ROOM ? BotLog::ROOM : BotLog::DM, c->name, "!gps fix", msg);
#ifdef DISPLAY_CLASS #ifdef DISPLAY_CLASS
if (_listener) _listener->addDMMsg(c->id.pub_key, true, msg); if (_listener) _listener->addDMMsg(c->id.pub_key, true, msg);
#endif #endif
+20 -1
View File
@@ -10,8 +10,17 @@
namespace botcfg { namespace botcfg {
// Log: the bot's recent replies (the_mesh.botLog()), where there's the
// memory for them (BOT_LOG, BotLog.h). A tab of its own, no setting rows.
#ifdef BOT_LOG
enum Tab : uint8_t { TAB_CHANNEL, TAB_ROOM, TAB_DIRECT, TAB_OTHER, TAB_LOG, TAB_COUNT };
static const char* const TAB_LABELS[TAB_COUNT] = { "Channel", "Room", "Direct", "Other", "Log" };
static bool isLog(int tab) { return tab == TAB_LOG; }
#else
enum Tab : uint8_t { TAB_CHANNEL, TAB_ROOM, TAB_DIRECT, TAB_OTHER, TAB_COUNT }; enum Tab : uint8_t { TAB_CHANNEL, TAB_ROOM, TAB_DIRECT, TAB_OTHER, TAB_COUNT };
static const char* const TAB_LABELS[TAB_COUNT] = { "Channel", "Room", "Direct", "Other" }; static const char* const TAB_LABELS[TAB_COUNT] = { "Channel", "Room", "Direct", "Other" };
static bool isLog(int) { return false; }
#endif
enum Kind : uint8_t { ENABLE_DM, DM_SCOPE, COMMANDS_DM, ACTIONS_DM, TRIGGER_DM, REPLY_DM, 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_CH, CHANNEL, COMMANDS_CH, ACTIONS_CH, TRIGGER_CH, REPLY_CH,
@@ -56,7 +65,8 @@ static int rowCount(int tab) {
case TAB_CHANNEL: return sizeof(CHANNEL_ROWS) / sizeof(Row); case TAB_CHANNEL: return sizeof(CHANNEL_ROWS) / sizeof(Row);
case TAB_ROOM: return sizeof(ROOM_ROWS) / sizeof(Row); case TAB_ROOM: return sizeof(ROOM_ROWS) / sizeof(Row);
case TAB_DIRECT: return sizeof(DIRECT_ROWS) / sizeof(Row); case TAB_DIRECT: return sizeof(DIRECT_ROWS) / sizeof(Row);
default: return sizeof(OTHER_ROWS) / sizeof(Row); case TAB_OTHER: return sizeof(OTHER_ROWS) / sizeof(Row);
default: return 0; // Log
} }
} }
@@ -111,6 +121,15 @@ static bool quietOff(const NodePrefs* p) { return p->bot_quiet_start == p->bot_q
// Placeholders a reply can use: the bot fills {name} / {hops} / {path} 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. // message it answers; {loc} / {time} and the sensor ones come from here.
// Most useful first (a frontend short of room shows a prefix). // Most useful first (a frontend short of room shows a prefix).
// A log entry's one-line form: "14:30 Bob: !snr > SNR 6.5dB ...", the time
// local (tz hours); a room / channel entry marked with # / @.
static void logLine(const BotLogEntry& e, int8_t tz, char* out, size_t n) {
uint32_t t = e.ts + (int32_t)tz * 3600;
const char* mark = e.where == BotLog::CHANNEL ? "#" : e.where == BotLog::ROOM ? "@" : "";
snprintf(out, n, "%02u:%02u %s%s: %s > %s", (unsigned)(t / 3600 % 24), (unsigned)(t / 60 % 60), mark,
e.who[0] ? e.who : "?", e.req, e.reply);
}
static const char* const REPLY_PLACEHOLDERS[] = { "{name}", "{hops}", "{path}", "{snr}", "{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]); static const int REPLY_PLACEHOLDER_COUNT = sizeof(REPLY_PLACEHOLDERS) / sizeof(REPLY_PLACEHOLDERS[0]);
@@ -93,6 +93,31 @@ void UITask::buildBot() {
lv_obj_t* list = scrollList(body); lv_obj_t* list = scrollList(body);
lv_obj_t* g = group(list, nullptr); lv_obj_t* g = group(list, nullptr);
if (botcfg::isLog(s_tab)) { // the replies, newest first: when and who, then what was asked and answered
const BotLog& log = the_mesh.botLog();
lv_obj_set_style_pad_hor(g, theme::PAD, 0);
lv_obj_set_style_pad_ver(g, 6, 0);
if (log.count() == 0) label(g, "No replies yet", THEME_FONT_BODY, theme::TEXT_MUTED);
for (int i = 0; i < log.count(); i++) {
const BotLogEntry& e = log.at(i);
const uint32_t t = e.ts + (int32_t)p->tz_offset_hours * 3600;
static const char* const WHERE[] = { "DM", "channel", "room" };
char hd[64];
snprintf(hd, sizeof(hd), "%02u:%02u %s (%s)", (unsigned)(t / 3600 % 24), (unsigned)(t / 60 % 60),
e.who[0] ? e.who : "?", WHERE[e.where < 3 ? e.where : 0]);
label(g, hd, THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_t* q = label(g, e.req, THEME_FONT_SMALL, theme::TEXT);
lv_label_set_long_mode(q, LV_LABEL_LONG_WRAP);
lv_obj_set_width(q, lv_pct(100));
lv_obj_t* a = label(g, "", THEME_FONT_BODY, theme::ACCENT);
lv_label_set_text_fmt(a, LV_SYMBOL_RIGHT " %s", e.reply);
lv_label_set_long_mode(a, LV_LABEL_LONG_WRAP);
lv_obj_set_width(a, lv_pct(100));
if (i + 1 < log.count()) lv_obj_set_style_pad_bottom(a, 8, 0);
}
return;
}
for (int i = 0; i < botcfg::rowCount(s_tab); i++) { for (int i = 0; i < botcfg::rowCount(s_tab); i++) {
const botcfg::Row& r = botcfg::row(s_tab, i); const botcfg::Row& r = botcfg::row(s_tab, i);
uint8_t* fl = botcfg::flag(p, r.kind); uint8_t* fl = botcfg::flag(p, r.kind);
+28 -1
View File
@@ -106,6 +106,8 @@ public:
display.setColor(UIColor::primary_txt); display.setColor(UIColor::primary_txt);
} }
if (botcfg::isLog(_tab)) return renderLog(display);
int n = botcfg::rowCount(_tab); int n = botcfg::rowCount(_tab);
int mq_delay = 0; int mq_delay = 0;
drawList(display, n, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { drawList(display, n, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
@@ -143,6 +145,29 @@ public:
return 2000; return 2000;
} }
// Log tab: the bot's replies, newest first, one line each (the selected
// one scrolls to show the rest).
int renderLog(DisplayDriver& display) {
const BotLog& log = the_mesh.botLog();
if (log.count() == 0) {
display.drawTextCentered(display.width() / 2, display.height() / 2, "No replies yet");
return 2000;
}
const int8_t tz = _prefs ? _prefs->tz_offset_hours : 0;
int mq_delay = 0;
drawList(display, log.count(), _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
drawRowSelection(display, y, sel, reserve);
char line[200];
botcfg::logLine(log.at(i), tz, line, sizeof(line));
char filt[200];
display.translateUTF8ToBlocks(filt, line, sizeof(filt));
int r = display.drawTextEllipsized(2, y, display.width() - 2 - reserve, filt, sel);
if (sel && r > 0) mq_delay = r;
display.setColor(UIColor::primary_txt);
});
return mq_delay > 0 ? mq_delay : 2000;
}
bool handleInput(char c) override { bool handleInput(char c) override {
bool up = (c == KEY_UP); bool up = (c == KEY_UP);
bool down = (c == KEY_DOWN); bool down = (c == KEY_DOWN);
@@ -180,12 +205,14 @@ public:
if (keyIsPrev(c)) { tabbar::turn(_task->getDisplay(), botcfg::TAB_LABELS, _tab, -1); _tab = (_tab + botcfg::TAB_COUNT - 1) % botcfg::TAB_COUNT; _sel = _scroll = 0; return true; } if (keyIsPrev(c)) { tabbar::turn(_task->getDisplay(), botcfg::TAB_LABELS, _tab, -1); _tab = (_tab + botcfg::TAB_COUNT - 1) % botcfg::TAB_COUNT; _sel = _scroll = 0; return true; }
if (keyIsNext(c)) { tabbar::turn(_task->getDisplay(), botcfg::TAB_LABELS, _tab, +1); _tab = (_tab + 1) % botcfg::TAB_COUNT; _sel = _scroll = 0; return true; } if (keyIsNext(c)) { tabbar::turn(_task->getDisplay(), botcfg::TAB_LABELS, _tab, +1); _tab = (_tab + 1) % botcfg::TAB_COUNT; _sel = _scroll = 0; return true; }
int n = botcfg::rowCount(_tab); int n = botcfg::isLog(_tab) ? the_mesh.botLog().count() : botcfg::rowCount(_tab);
if (n == 0) return up || down || enter;
// drawList() reclamps _scroll from _sel every render. // drawList() reclamps _scroll from _sel every render.
if (up) { _sel = (_sel > 0) ? _sel - 1 : n - 1; return true; } if (up) { _sel = (_sel > 0) ? _sel - 1 : n - 1; return true; }
if (down) { _sel = (_sel < n - 1) ? _sel + 1 : 0; return true; } if (down) { _sel = (_sel < n - 1) ? _sel + 1 : 0; return true; }
if (!enter) return false; if (!enter) return false;
if (botcfg::isLog(_tab)) return true; // only read
Kind k = tabRow(_tab, _sel).kind; Kind k = tabRow(_tab, _sel).kind;
if (uint8_t* fl = botcfg::flag(_prefs, k)) { *fl ^= 1; _dirty = true; return true; } if (uint8_t* fl = botcfg::flag(_prefs, k)) { *fl ^= 1; _dirty = true; return true; }
+1 -1
View File
@@ -190,7 +190,7 @@ if [ "$SIM_UI" = "lvgl" ]; then
done done
# The L2's PSRAM-sized limits (solo/wio-tracker-l2/platformio.ini). # The L2's PSRAM-sized limits (solo/wio-tracker-l2/platformio.ini).
DEFINES=("${kept[@]}" -DDISPLAY_CLASS=SimLcdDisplay -DMAX_GROUP_CHANNELS=40 -DUI_ZOOM=1 -DLV_CONF_INCLUDE_SIMPLE DEFINES=("${kept[@]}" -DDISPLAY_CLASS=SimLcdDisplay -DMAX_GROUP_CHANNELS=40 -DUI_ZOOM=1 -DLV_CONF_INCLUDE_SIMPLE
-DTRAIL_CAPACITY=32768 -DTRAIL_SIMPLIFY=0 -DTRAIL_FIXED_MIN_DELTA_M=5 -DWAYPOINT_CAPACITY=64 -DHIST_CH_MAX=256 -DHIST_DM_MAX=128 -DNEARBY_MAX=64 -DHIST_ARCHIVE) -DTRAIL_CAPACITY=32768 -DTRAIL_SIMPLIFY=0 -DTRAIL_FIXED_MIN_DELTA_M=5 -DWAYPOINT_CAPACITY=64 -DHIST_CH_MAX=256 -DHIST_DM_MAX=128 -DNEARBY_MAX=64 -DHIST_ARCHIVE -DBOT_LOG_SIM)
INCLUDES+=("-I$LVGL_DIR") INCLUDES+=("-I$LVGL_DIR")
LINK_EXTRA=(-sFETCH=1) # map downloads (LvglPort.h) go through emscripten_fetch LINK_EXTRA=(-sFETCH=1) # map downloads (LvglPort.h) go through emscripten_fetch
fi fi