From 98e8e63af916bda4a88543647463c5100b348131 Mon Sep 17 00:00:00 2001 From: Kaj Schittecat Date: Sat, 22 Aug 2026 20:50:59 +0200 Subject: [PATCH] console mode: colour, a boot banner, a quick menu, and the audit's missing commands Colour: each scrollback line carries a ConsoleColor, so meaning picks the colour rather than the caller's taste. Incoming channel posts, DMs, your own echoed command, successes, warnings and failures are now distinguishable at a glance. The palette maps onto the core's UIColor names so the console tracks the rest of the firmware instead of inventing values. Banner: an ASCII mark, the node name and build, then the quick menu, in the login-banner tradition. 19 columns wide so it does not break on the V4's 240 px panel. Quick menu: numbered, because typing 1 beats typing 'contacts' and because a numbered list is how you find out what exists. A bare digit runs the item; it is checked before everything else so the node CLI cannot shadow it. The audit (full table in CONSOLE_MODE.md) compared every UI surface against what the console offered. The node CLI already covers more than expected: advert, clock, gps, region, get/set, log, neighbors, ota, power, sensor, time, ver. What it does NOT cover, now added: chat read a thread (does NOT mark it read) stat radio + traffic, the Signal page's numbers batt battery wifi radio state discover / discovered the active probe, with BOTH link directions luaHostBattery and luaHostMeshDiscover were CAP_LUA_SDK_EXT-gated, so the V4 -- the board this exists for -- failed to link. Widened to || CAP_CONSOLE, same as the send path earlier. Co-Authored-By: Claude Opus 5 --- src/ui-touch/ConsoleUI.cpp | 201 +++++++++++++++++++++++++++++++++++-- src/ui-touch/ConsoleUI.h | 22 ++++ src/ui-touch/UITask.cpp | 39 +++++-- src/ui-touch/UITask.h | 2 + 4 files changed, 251 insertions(+), 13 deletions(-) diff --git a/src/ui-touch/ConsoleUI.cpp b/src/ui-touch/ConsoleUI.cpp index 5e0bc33..d33ef5d 100644 --- a/src/ui-touch/ConsoleUI.cpp +++ b/src/ui-touch/ConsoleUI.cpp @@ -17,6 +17,7 @@ #include "../MyMesh.h" // declares the_mesh itself (a reference on PSRAM boards) #if defined(ESP32) #include "../helpers/esp32/TouchPrefsStore.h" + #include "../helpers/esp32/WifiRuntimeStore.h" // wifiConfigGetRadioEnabled #endif // Contacts, channels and the send path, all from UITask so the console reuses @@ -34,6 +35,16 @@ extern bool luaHostMeshSendDM(const char* to_name, const char* text, bool* was_r extern int consoleHostUnreadTotal(); extern int consoleHostThreadAt(int idx, char* name, size_t cap, int* unread, bool* is_channel); extern bool consoleHostMarkRead(const char* name); +extern int consoleHostHistoryAt(const char* thread, int back, char* sender, size_t sc, + char* text, size_t tc, uint32_t* ts, bool* outgoing); +extern void luaHostRadioStats(float* rssi, float* noise, uint32_t* rx_air_s, uint32_t* tx_air_s, + uint32_t* rx_pkts, uint32_t* rx_err, int* budget_ms); +extern void luaHostBattery(uint16_t* mv, int* pct, bool* charging); +extern uint32_t luaHostMeshDiscover(int type_filter); +extern int luaHostDiscoverCount(); +extern int luaHostDiscoverAt(int idx, char* pk_hex, size_t pk_cap, char* name, size_t name_cap, + int* type, int* rssi, float* snr, float* their_snr, int* hops, + uint32_t* first_ms_ago, uint32_t* last_ms_ago, int* heard); #if CAP_TOUCH // Board touch driver, read directly. lvglTouchRead() is only an adapter that @@ -52,6 +63,7 @@ constexpr int kLineCap = 96; // chars per stored line, excluding the NUL constexpr int kInputCap = 128; char* s_ring = nullptr; // kMaxLines * (kLineCap + 1) +uint8_t* s_ring_col = nullptr; // one ConsoleColor per stored line int s_head = 0; // next write slot int s_count = 0; int s_scroll = 0; // lines scrolled back from the newest @@ -75,12 +87,15 @@ bool s_blink_on = true; inline char* lineAt(int i) { return s_ring + (size_t)i * (kLineCap + 1); } +uint8_t s_push_col = CC_TEXT; // colour applied by the next ringPush + void ringPush(const char* s, int len) { if (!s_ring) return; if (len > kLineCap) len = kLineCap; char* dst = lineAt(s_head); memcpy(dst, s, len); dst[len] = '\0'; + if (s_ring_col) s_ring_col[s_head] = s_push_col; s_head = (s_head + 1) % kMaxLines; if (s_count < kMaxLines) s_count++; s_scroll = 0; // any new output jumps back to the live tail @@ -97,6 +112,28 @@ const char* ringGet(int i) { const int start = (s_head - s_count + kMaxLines * 2) % kMaxLines; return lineAt((start + i) % kMaxLines); } +uint8_t ringGetCol(int i) { + if (!s_ring_col || i < 0 || i >= s_count) return CC_TEXT; + const int start = (s_head - s_count + kMaxLines * 2) % kMaxLines; + return s_ring_col[(start + i) % kMaxLines]; +} + +// One place that decides what a colour means on this panel. UIColor is the +// core's named set, so the console tracks the rest of the firmware rather than +// inventing its own values. +uint16_t colourFor(uint8_t c) { + switch (c) { + case CC_DIM: return UIColor::secondary_txt; + case CC_ECHO: return UIColor::title_txt; + case CC_OK: return UIColor::corp_blue; + case CC_WARN: return UIColor::warning_txt; + case CC_ERR: return UIColor::warning_txt; + case CC_CHAN: return UIColor::corp_blue; + case CC_DM: return UIColor::title_txt; + case CC_HEAD: return UIColor::title_txt; + default: return UIColor::primary_txt; + } +} #if CAP_TOUCH && !CAP_KEYBOARD // ---- on-screen keypad ------------------------------------------------------- @@ -246,7 +283,7 @@ void render() { const int idx = first + r; const char* l = (idx >= 0) ? ringGet(idx) : nullptr; if (l && *l) { - s_disp->setColor(UIColor::primary_txt); + s_disp->setColor(colourFor(ringGetCol(idx))); s_disp->setCursor(0, y); s_disp->print(l); } @@ -335,6 +372,48 @@ void drawCursorOnly(bool on) { s_disp->fillRect(s_cur_x, s_cur_y, s_char_w, s_line_h); } +// ---- boot banner + quick menu ----------------------------------------------- +// A login banner in the server tradition: what this machine is, then what it can +// do, so the first screen answers "now what?" instead of leaving a bare prompt. +// Deliberately narrow: 26 columns fits the smallest panel we ship (the V4 at +// 240 px), so nobody sees a broken box. +const char* const kBanner[] = { + " _ _ _ ___ ___ ___ ", + "| | | | .'| | _|", + "|_____|__,|_|_|_| ", + " m e s h", +}; + +// The quick menu. Numbers because typing 1 is faster than typing 'contacts', +// and because a numbered list is how you discover what exists at all. +struct QuickItem { const char* key; const char* label; const char* cmd; }; +const QuickItem kQuick[] = { + { "1", "Unread", "unread" }, + { "2", "Contacts", "contacts" }, + { "3", "Channels", "chans" }, + { "4", "Discover", "discover" }, + { "5", "Signal", "stat" }, + { "6", "Battery", "batt" }, + { "7", "Wi-Fi", "wifi" }, + { "8", "Memory", "mem" }, + { "9", "Node", "status" }, +}; +const int kQuickN = (int)(sizeof(kQuick) / sizeof(kQuick[0])); + +void cmdMenu() { + consoleWriteLineC(CC_HEAD, "quick launch"); + for (int i = 0; i < kQuickN; i += 2) { + char line[kLineCap]; + if (i + 1 < kQuickN) + snprintf(line, sizeof line, " %s %-10s %s %-10s", + kQuick[i].key, kQuick[i].label, kQuick[i + 1].key, kQuick[i + 1].label); + else + snprintf(line, sizeof line, " %s %-10s", kQuick[i].key, kQuick[i].label); + consoleWriteLine(line); + } + consoleWriteLineC(CC_DIM, "type a number, or 'help'"); +} + // ---- command dispatch ------------------------------------------------------- void submit() { if (s_input_len == 0) return; @@ -343,10 +422,21 @@ void submit() { consoleWriteLine(""); // blank line between commands, for legibility char echo[kLineCap + 4]; snprintf(echo, sizeof echo, "> %s", cmd); - consoleWriteLine(echo); + consoleWriteLineC(CC_ECHO, echo); s_input[0] = '\0'; s_input_len = 0; + // A bare number is a quick-launch shortcut. Checked first so it cannot be + // shadowed by anything the node CLI happens to accept. + if (cmd[0] >= '1' && cmd[0] <= '9' && cmd[1] == '\0') { + for (int i = 0; i < kQuickN; i++) { + if (kQuick[i].key[0] != cmd[0]) continue; + snprintf(cmd, sizeof cmd, "%s", kQuick[i].cmd); + break; + } + } + if (!strcasecmp(cmd, "menu") || !strcasecmp(cmd, "apps")) { cmdMenu(); return; } + // Console-only commands first, then everything else to the node CLI. Keeping // this list short is deliberate: anything CommonCLI already answers should go // there rather than being reimplemented here. @@ -370,6 +460,9 @@ void submit() { if (!strcasecmp(cmd, "help")) { consoleWriteLine("console clear, help, mem, ui"); consoleWriteLine("mesh contacts, chans, unread"); + consoleWriteLine(" chat show a thread"); + consoleWriteLine(" discover / discovered"); + consoleWriteLine("radio stat, batt, wifi"); consoleWriteLine(" read clear that thread's unread"); consoleWriteLine(" monitor on|off show arriving messages"); consoleWriteLine(" to pick a contact or channel"); @@ -425,7 +518,68 @@ void submit() { if (!strncasecmp(cmd, "read ", 5)) { const char* who = cmd + 5; if (consoleHostMarkRead(who)) consolePrintf("marked %s read", who); - else consolePrintf("no thread named '%s'", who); + else consolePrintfC(CC_ERR, "no thread named '%s'", who); + return; + } + // Read a thread. Deliberately does NOT mark it read: use 'read '. + if (!strncasecmp(cmd, "chat ", 5)) { + const char* who = cmd + 5; + char sender[40], text[176]; uint32_t ts; bool out; + int shown = 0; + // Collect newest-first, print oldest-first, so it reads like a conversation. + for (int i = 11; i >= 0; i--) { + if (!consoleHostHistoryAt(who, i, sender, sizeof sender, text, sizeof text, &ts, &out)) continue; + consolePrintfC(out ? CC_DIM : CC_TEXT, "%s%s: %s", out ? "> " : "", sender, text); + shown++; + } + if (!shown) consolePrintfC(CC_WARN, "nothing stored for '%s'", who); + else consoleWriteLineC(CC_DIM, "('read ' to clear unread)"); + return; + } + // Radio + traffic, the Signal page's numbers. + if (!strcasecmp(cmd, "stat") || !strcasecmp(cmd, "signal")) { + float rssi, noise; uint32_t rxa, txa, rxp, rxe; int budget; + luaHostRadioStats(&rssi, &noise, &rxa, &txa, &rxp, &rxe, &budget); + consolePrintf("rssi %.0f noise %.0f margin %.0f dB", rssi, noise, rssi - noise); + consolePrintf("rx %lu pkts, %lu err", (unsigned long)rxp, (unsigned long)rxe); + consolePrintf("airtime rx %lus tx %lus", (unsigned long)rxa, (unsigned long)txa); + consolePrintfC(budget > 0 ? CC_OK : CC_WARN, "tx budget %d ms", budget); + return; + } + if (!strcasecmp(cmd, "batt") || !strcasecmp(cmd, "battery")) { + uint16_t mv = 0; int pct = -1; bool chg = false; + luaHostBattery(&mv, &pct, &chg); + consolePrintfC(pct >= 0 && pct < 20 ? CC_WARN : CC_TEXT, + "%d%% %u mV%s", pct, (unsigned)mv, chg ? " charging" : ""); + return; + } + if (!strcasecmp(cmd, "wifi")) { +#if defined(ESP32) + consolePrintf("wifi %s", wifiConfigGetRadioEnabled() ? "on" : "off"); + consoleWriteLineC(CC_DIM, "(use the CLI: 'set wifi.ssid', or the UI)"); +#endif + return; + } + // The active probe. It transmits and makes every neighbour reply, so it says + // so rather than doing it silently. + if (!strcasecmp(cmd, "discover")) { + if (!luaHostMeshDiscover(0)) { consoleWriteLineC(CC_ERR, "probe failed (radio busy?)"); return; } + consoleWriteLineC(CC_OK, "probe sent - replies arrive over a few seconds"); + consoleWriteLineC(CC_DIM, "'discovered' to list who answered"); + return; + } + if (!strcasecmp(cmd, "discovered")) { + char pk[12], name[36]; int type, rssi, hops, heard; float snr, tsnr; uint32_t fa, la; + const int n = luaHostDiscoverCount(); + for (int i = 0; i < n && i < 30; i++) { + if (!luaHostDiscoverAt(i, pk, sizeof pk, name, sizeof name, &type, &rssi, &snr, + &tsnr, &hops, &fa, &la, &heard)) break; + // Both link directions: theirs matters as much as ours, and only a probe + // reply can tell you how well they heard YOU. + consolePrintfC(hops == 0 ? CC_OK : CC_TEXT, "%-14s %5.1f/%-5.1f %s", + name[0] ? name : pk, snr, tsnr, hops == 0 ? "direct" : "relayed"); + } + if (!n) consoleWriteLineC(CC_DIM, "nothing yet - run 'discover' first"); return; } if (!strcasecmp(cmd, "contacts")) { cmdContacts(); return; } @@ -436,17 +590,17 @@ void submit() { return; } if (!strncasecmp(cmd, "msg ", 4)) { - if (!s_to[0]) { consoleWriteLine("no recipient - use 'to ' first"); return; } + if (!s_to[0]) { consoleWriteLineC(CC_WARN, "no recipient - use 'to ' first"); return; } const char* text = cmd + 4; // Try a channel first, then a contact. Channels and contacts share a name // space here on purpose: the user typed a name, not a category. - if (luaHostMeshSendChannel(s_to, text)) { consolePrintf("sent to #%s", s_to); return; } + if (luaHostMeshSendChannel(s_to, text)) { consolePrintfC(CC_OK, "sent to #%s", s_to); return; } bool was_room = false; if (luaHostMeshSendDM(s_to, text, &was_room)) { - consolePrintf("sent to %s%s", s_to, was_room ? " (room)" : ""); + consolePrintfC(CC_OK, "sent to %s%s", s_to, was_room ? " (room)" : ""); return; } - consolePrintf("no channel or contact named '%s'", s_to); + consolePrintfC(CC_ERR, "no channel or contact named '%s'", s_to); return; } the_mesh.runLocalCli(cmd); @@ -466,6 +620,11 @@ void consoleBegin(DisplayDriver* d) { if (!s_ring) s_ring = (char*)malloc(bytes); if (!s_ring) return; // no scrollback, no console memset(s_ring, 0, bytes); +#if defined(ESP32) + s_ring_col = (uint8_t*)heap_caps_malloc(kMaxLines, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); +#endif + if (!s_ring_col) s_ring_col = (uint8_t*)malloc(kMaxLines); + if (s_ring_col) memset(s_ring_col, CC_TEXT, kMaxLines); // null is tolerated: all text } s_active = true; measure(); @@ -473,6 +632,19 @@ void consoleBegin(DisplayDriver* d) { render(); } +// Printed once at boot by UITask::begin. Not inside consoleBegin, because the +// node name and version are not knowable until the mesh is up. +void consoleBanner(const char* node_name, const char* version) { + for (unsigned i = 0; i < sizeof(kBanner) / sizeof(kBanner[0]); i++) + consoleWriteLineC(CC_HEAD, kBanner[i]); + consoleWriteLine(""); + if (node_name && *node_name) consolePrintfC(CC_OK, "node %s", node_name); + if (version && *version) consolePrintfC(CC_DIM, "build %s", version); + consoleWriteLine(""); + cmdMenu(); + consoleWriteLine(""); +} + void consoleEnd() { s_active = false; } bool consoleActive() { return s_active; } @@ -513,6 +685,21 @@ void consoleWriteLine(const char* line) { } } +void consoleWriteLineC(uint8_t colour, const char* line) { + s_push_col = colour; + consoleWriteLine(line); + s_push_col = CC_TEXT; +} + +void consolePrintfC(uint8_t colour, const char* fmt, ...) { + char buf[256]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof buf, fmt, ap); + va_end(ap); + consoleWriteLineC(colour, buf); +} + void consolePrintf(const char* fmt, ...) { char buf[256]; va_list ap; diff --git a/src/ui-touch/ConsoleUI.h b/src/ui-touch/ConsoleUI.h index 6652a7a..ef51e6b 100644 --- a/src/ui-touch/ConsoleUI.h +++ b/src/ui-touch/ConsoleUI.h @@ -12,6 +12,8 @@ // skipping LVGL entirely is Phase 2; this module is self-contained until then // so it can be exercised without touching the graphical path. #include "device_caps.h" +#include +#include #if CAP_CONSOLE class DisplayDriver; @@ -25,10 +27,30 @@ bool consoleActive(); // Call every loop while active: polls touch, blinks the cursor, redraws when dirty. void consoleLoop(); +// Line colours. Kept as a small named set rather than raw values so the console +// reads as one thing: the meaning of a line picks the colour, not the caller's +// taste. +enum ConsoleColor : uint8_t { + CC_TEXT = 0, // normal output + CC_DIM, // secondary / hints + CC_ECHO, // the command you typed + CC_OK, // success + CC_WARN, // warnings, refusals + CC_ERR, // failures + CC_CHAN, // an incoming channel message + CC_DM, // an incoming direct message + CC_HEAD, // section headings, the banner +}; + // Append one line of output. This is the MyMesh terminal-sink signature, so it // can be handed to setTerminalSink() directly. Wraps long lines. void consoleWriteLine(const char* line); +void consoleWriteLineC(uint8_t colour, const char* line); void consolePrintf(const char* fmt, ...); +void consolePrintfC(uint8_t colour, const char* fmt, ...); + +// The login banner: ASCII mark, who this node is, and the quick-launch menu. +void consoleBanner(const char* node_name, const char* version); // Feed one character from a hardware keyboard. '\n' submits, '\b' deletes. // Returns true if the console consumed it. diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 05bde0a..e9673a4 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -23297,6 +23297,10 @@ int consoleHostThreadAt(int idx, char* name, size_t cap, int* unread, bool* is_c bool consoleHostMarkRead(const char* name) { return g_lv.task ? g_lv.task->consoleMarkThreadRead(name) : false; } +int consoleHostHistoryAt(const char* thread, int back, char* sender, size_t sc, + char* text, size_t tc, uint32_t* ts, bool* outgoing) { + return g_lv.task ? g_lv.task->consoleHistoryAt(thread, back, sender, sc, text, tc, ts, outgoing) : 0; +} #endif #if CAP_CONSOLE @@ -46967,7 +46971,7 @@ bool luaHostMeshSendChannel(const char* chan_name, const char* text) { } #endif // CAP_LUA_SDK_EXT || CAP_CONSOLE -#if CAP_LUA_SDK_EXT +#if CAP_LUA_SDK_EXT || CAP_CONSOLE // console mode needs it on the V4 too // wada.sys.battery(). Reads the SMOOTHED millivolts the status bar uses, not a // raw ADC sample, so a Lua chart cannot show noise the rest of the UI hides. void luaHostBattery(uint16_t* mv, int* pct, bool* charging) { @@ -47001,9 +47005,9 @@ bool luaHostGps(double* lat, double* lon, int* sats, int* alt_m, uint32_t* fix_t if (lon_e6) *lon_e6 = (int32_t)llround(lo * 1.0e6); return true; } -#endif // CAP_LUA_SDK_EXT +#endif // CAP_LUA_SDK_EXT || CAP_CONSOLE -#if CAP_LUA_SDK_EXT +#if CAP_LUA_SDK_EXT || CAP_CONSOLE // console mode needs it on the V4 too // ---- Settings -> App permissions ------------------------------------------- // You could GRANT a permission but not review or take one back, which is only // acceptable on a bench. "Which apps can transmit as me?" has to be answerable, @@ -47162,7 +47166,7 @@ void luaHostRequestProbePerm(const char* app_id, const char* app_name) { uint32_t luaHostMeshDiscover(int type_filter) { return the_mesh.uiStartDiscoverScan((uint8_t)(type_filter & 0xFF)); } -#endif // CAP_LUA_SDK_EXT +#endif // CAP_LUA_SDK_EXT || CAP_CONSOLE void luaHostSelfInfo(char* name, size_t name_cap, double* lat, double* lon, char* pk_hex, size_t pk_cap, int32_t* lat_e6, int32_t* lon_e6) { @@ -48624,6 +48628,28 @@ int UITask::consoleThreadAt(int idx, char* name, size_t cap, int* unread, bool* return 0; } +// Read back a thread's stored messages, NEWEST first. Read-only, like +// consoleThreadAt: showing history is not the same act as clearing unread, so +// the caller decides whether to also mark it read. +int UITask::consoleHistoryAt(const char* thread, int back, char* sender, size_t sc, + char* text, size_t tc, uint32_t* ts, bool* outgoing) { + if (!_ui_msgs || !thread || !*thread || back < 0) return 0; + int seen = 0; + for (int n = 0; n < _ui_msg_count; ++n) { + // walk backwards from the newest record in the ring + int i = (_ui_msg_head - 1 - n + _ui_msg_cap * 2) % _ui_msg_cap; + const UIMessage& m = _ui_msgs[i]; + if (strncmp(m.thread, thread, MAX_THREAD_NAME) != 0) continue; + if (seen++ != back) continue; + snprintf(sender, sc, "%s", m.sender); + snprintf(text, tc, "%s", m.text); + if (ts) *ts = m.ts; + if (outgoing) *outgoing = m.outgoing; + return 1; + } + return 0; +} + // Clearing unread is an explicit act, which is the whole point: the monitor // showing a message must not count as having read it. bool UITask::consoleMarkThreadRead(const char* name) { @@ -49396,8 +49422,9 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no Serial.println("[BOOT] console: begin"); Serial.flush(); consoleBegin(_display); Serial.println("[BOOT] console: panel up"); Serial.flush(); - consoleWriteLine("wadamesh console"); - consoleWriteLine("type 'help', or 'ui' to go back to the graphical UI"); + consoleBanner(the_mesh.getNodePrefs() ? the_mesh.getNodePrefs()->node_name : nullptr, + FIRMWARE_VERSION); + consoleWriteLineC(CC_DIM, "'help' for everything, 'ui' for the graphical interface"); consoleWriteLine(""); Serial.println("[BOOT] console: text ok"); Serial.flush(); the_mesh.setTerminalSink(&consoleWriteLine); diff --git a/src/ui-touch/UITask.h b/src/ui-touch/UITask.h index 257f932..62e6289 100644 --- a/src/ui-touch/UITask.h +++ b/src/ui-touch/UITask.h @@ -318,6 +318,8 @@ public: // one deliberately. Kept separate so the monitor cannot mark anything read. int consoleThreadAt(int idx, char* name, size_t cap, int* unread, bool* is_channel); bool consoleMarkThreadRead(const char* name); + int consoleHistoryAt(const char* thread, int back, char* sender, size_t sc, + char* text, size_t tc, uint32_t* ts, bool* outgoing); void markActiveThreadRead(); // clear the currently-open thread's unread (viewing == read) void markAllThreadsRead(); // clear every thread's unread count bool threadHasMention(int idx) const; // unread @mention of me in this thread