diff --git a/docs/solo/tools.md b/docs/solo/tools.md index a4db5282..fa2bd809 100644 --- a/docs/solo/tools.md +++ b/docs/solo/tools.md @@ -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 diff --git a/examples/companion_radio/BotLog.h b/examples/companion_radio/BotLog.h new file mode 100644 index 00000000..b30838a6 --- /dev/null +++ b/examples/companion_radio/BotLog.h @@ -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 +#include +#if defined(ESP32) || defined(BOT_LOG_SIM) + #define BOT_LOG 1 +#endif +#if defined(ESP32) + #include +#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 +}; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index a237f2ec..0679eada 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -43,6 +43,7 @@ class UITask; #include "DataStore.h" #include "NodePrefs.h" #include "ScopeList.h" +#include "BotLog.h" #include #include @@ -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 diff --git a/examples/companion_radio/MyMeshBot.h b/examples/companion_radio/MyMeshBot.h index 3b128efe..74bf21d4 100644 --- a/examples/companion_radio/MyMeshBot.h +++ b/examples/companion_radio/MyMeshBot.h @@ -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 diff --git a/examples/companion_radio/ui-core/BotConfig.h b/examples/companion_radio/ui-core/BotConfig.h index 752e274c..b4c600f1 100644 --- a/examples/companion_radio/ui-core/BotConfig.h +++ b/examples/companion_radio/ui-core/BotConfig.h @@ -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]); diff --git a/examples/companion_radio/ui-lvgl/BotScreen.h b/examples/companion_radio/ui-lvgl/BotScreen.h index 6fbc6c36..8088133d 100644 --- a/examples/companion_radio/ui-lvgl/BotScreen.h +++ b/examples/companion_radio/ui-lvgl/BotScreen.h @@ -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); diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index 854be4d0..b9cb4c4d 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -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; } diff --git a/variants/sim/build_wasm.sh b/variants/sim/build_wasm.sh index 93af3157..60c6d47f 100755 --- a/variants/sim/build_wasm.sh +++ b/variants/sim/build_wasm.sh @@ -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