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
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**
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
+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 "NodePrefs.h"
#include "ScopeList.h"
#include "BotLog.h"
#include <RTClib.h>
#include <helpers/ArduinoHelpers.h>
@@ -587,6 +588,7 @@ public:
// Number of auto-replies sent since boot (DM + channel + room). Shown on BotScreen.
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
// 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;
BotReplyLog _bot_dm_log[BOT_DM_LOG_SIZE];
uint16_t _bot_reply_count; // total auto-replies sent since boot
BotLog _bot_log;
// !gps fix state -- one global slot (one physical GPS): botCommandReply()
// 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) {
botDmRecord(from.id.pub_key);
_bot_reply_count++;
_bot_log.add(ts, BotLog::DM, from.name, text, expanded);
#ifdef DISPLAY_CLASS
if (_listener) _listener->addDMMsg(from.id.pub_key, true, expanded);
#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)) {
_bot_last_ch_reply_ms = millis();
_bot_reply_count++;
_bot_log.add(ts, BotLog::CHANNEL, sender_name, msg, expanded);
#ifdef DISPLAY_CLASS
mirrorOwnChannelMsg(channel_idx, expanded);
#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) {
_bot_last_room_reply_ms = millis();
_bot_reply_count++;
_bot_log.add(ts, BotLog::ROOM, sender_name, text, expanded);
#ifdef DISPLAY_CLASS
if (_listener) _listener->addDMMsg(from.id.pub_key, true, expanded);
#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) {
botDmRecord(from.id.pub_key);
_bot_reply_count++;
_bot_log.add(ts, BotLog::DM, from.name, text, out);
#ifdef DISPLAY_CLASS
if (_listener) _listener->addDMMsg(from.id.pub_key, true, out);
#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)) {
_bot_last_ch_reply_ms = millis();
_bot_reply_count++;
_bot_log.add(ts, BotLog::CHANNEL, sender_name, msg, out);
#ifdef DISPLAY_CLASS
mirrorOwnChannelMsg(channel_idx, out);
#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) {
_bot_last_room_reply_ms = millis();
_bot_reply_count++;
_bot_log.add(ts, BotLog::ROOM, sender_name, text, out);
#ifdef DISPLAY_CLASS
if (_listener) _listener->addDMMsg(from.id.pub_key, true, out);
#endif
@@ -703,6 +709,7 @@ void MyMesh::sendLocFixResult(const char* msg) {
if (sendGroupMessage(ts, ch.channel, _prefs.node_name, msg, mlen)) {
_bot_last_ch_reply_ms = millis();
_bot_reply_count++;
_bot_log.add(ts, BotLog::CHANNEL, "", "!gps fix", msg);
#ifdef DISPLAY_CLASS
mirrorOwnChannelMsg(_loc_fix.channel_idx, msg);
#endif
@@ -715,6 +722,7 @@ void MyMesh::sendLocFixResult(const char* msg) {
if (c->type == ADV_TYPE_ROOM) _bot_last_room_reply_ms = millis();
else botDmRecord(c->id.pub_key);
_bot_reply_count++;
_bot_log.add(ts, c->type == ADV_TYPE_ROOM ? BotLog::ROOM : BotLog::DM, c->name, "!gps fix", msg);
#ifdef DISPLAY_CLASS
if (_listener) _listener->addDMMsg(c->id.pub_key, true, msg);
#endif
+20 -1
View File
@@ -10,8 +10,17 @@
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 };
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,
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_ROOM: return sizeof(ROOM_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
// message it answers; {loc} / {time} and the sensor ones come from here.
// 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 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* 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++) {
const botcfg::Row& r = botcfg::row(s_tab, i);
uint8_t* fl = botcfg::flag(p, r.kind);
+28 -1
View File
@@ -106,6 +106,8 @@ public:
display.setColor(UIColor::primary_txt);
}
if (botcfg::isLog(_tab)) return renderLog(display);
int n = botcfg::rowCount(_tab);
int mq_delay = 0;
drawList(display, n, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
@@ -143,6 +145,29 @@ public:
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 up = (c == KEY_UP);
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 (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.
if (up) { _sel = (_sel > 0) ? _sel - 1 : n - 1; return true; }
if (down) { _sel = (_sel < n - 1) ? _sel + 1 : 0; return true; }
if (!enter) return false;
if (botcfg::isLog(_tab)) return true; // only read
Kind k = tabRow(_tab, _sel).kind;
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
# 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
-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")
LINK_EXTRA=(-sFETCH=1) # map downloads (LvglPort.h) go through emscripten_fetch
fi