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 <name> 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
5c93c05a71
commit
98e8e63af9
4 files changed
+251
-13
No files matched your search
+194
-7
@@ -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 <name> show a thread");
|
||||
consoleWriteLine(" discover / discovered");
|
||||
consoleWriteLine("radio stat, batt, wifi");
|
||||
consoleWriteLine(" read <name> clear that thread's unread");
|
||||
consoleWriteLine(" monitor on|off show arriving messages");
|
||||
consoleWriteLine(" to <name> 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 <name>'.
|
||||
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 <name>' 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 <name>' first"); return; }
|
||||
if (!s_to[0]) { consoleWriteLineC(CC_WARN, "no recipient - use 'to <name>' 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;
|
||||
|
||||
Reference in new issue
Block a user