feat(ui): add on-device diagnostics screen (Tools > Diagnostics)

Shows packet counts by category (RX/TX), radio noise floor/RSSI/SNR,
packet-pool free count and outbound queue length, uptime, and heap/stack
headroom on a single scrollable screen.

Adds generic per-payload-type RX/TX counters to Dispatcher (mesh layer),
plus pool-free/queue-length getters, and a new DeviceDiag helper for
nRF52 heap (linker-symbol + sbrk) and stack (FreeRTOS high-water-mark)
stats — the only platform needed for the 3 in-scope boards (Wio Tracker
L1 OLED/Eink, GAT562 30S), all nRF52840.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-06-18 22:34:52 +02:00
parent 99cbe51797
commit 327e659d51
8 changed files with 200 additions and 2 deletions

View File

@@ -0,0 +1,137 @@
#pragma once
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/UIScreen.h>
#include <helpers/DeviceDiag.h>
#include <Arduino.h>
#include "icons.h"
#include "../MyMesh.h"
extern MyMesh the_mesh;
// Single-screen device diagnostics: packet counts by category (RX/TX), radio
// totals, uptime, heap and stack headroom. Built from Dispatcher's per-type
// counters (Mesh.h/Dispatcher.cpp) — grouped into a handful of categories
// rather than all 16 raw PAYLOAD_TYPE_* values, so it stays readable on a
// 128x64 OLED without needing the full type list. Falls back to scrolling
// (same drawList-style indicator as every other screen) on screens too small
// to show every row at once.
class DiagnosticsScreen : public UIScreen {
UITask* _task;
int _scroll = 0;
struct Row { const char* label; char value[20]; };
static const int MAX_ROWS = 12;
Row _rows[MAX_ROWS];
int _row_count = 0;
void addRow(const char* label, const char* value) {
if (_row_count >= MAX_ROWS) return;
_rows[_row_count].label = label;
strncpy(_rows[_row_count].value, value, sizeof(_rows[_row_count].value) - 1);
_rows[_row_count].value[sizeof(_rows[_row_count].value) - 1] = 0;
_row_count++;
}
// "12345/12345" rather than "rx12345 tx12345" — at 5-digit counts (a busy
// repeater after weeks of uptime) the longer form collides with the value
// column on a 128px OLED when paired with a long label like "Ack/Path".
// The "Total rx/tx" label spells out the rx/tx order once for every row below it.
void addRxTxRow(const char* label, uint32_t rx, uint32_t tx) {
char buf[20];
snprintf(buf, sizeof(buf), "%lu/%lu", (unsigned long)rx, (unsigned long)tx);
addRow(label, buf);
}
static uint32_t sumByTypes(bool recv, const uint8_t* types, int n) {
uint32_t total = 0;
for (int i = 0; i < n; i++)
total += recv ? the_mesh.getNumRecvByType(types[i]) : the_mesh.getNumSentByType(types[i]);
return total;
}
void buildRows() {
_row_count = 0;
uint32_t up_secs = millis() / 1000;
char buf[20];
uint32_t d = up_secs / 86400, h = (up_secs % 86400) / 3600, m = (up_secs % 3600) / 60, s = up_secs % 60;
if (d > 0) snprintf(buf, sizeof(buf), "%lud %02lu:%02lu:%02lu", (unsigned long)d, (unsigned long)h, (unsigned long)m, (unsigned long)s);
else snprintf(buf, sizeof(buf), "%02lu:%02lu:%02lu", (unsigned long)h, (unsigned long)m, (unsigned long)s);
addRow("Uptime", buf);
static const uint8_t MSG_TYPES[] = { PAYLOAD_TYPE_TXT_MSG, PAYLOAD_TYPE_GRP_TXT };
static const uint8_t ADVERT_TYPES[] = { PAYLOAD_TYPE_ADVERT };
static const uint8_t ROUTE_TYPES[] = { PAYLOAD_TYPE_ACK, PAYLOAD_TYPE_PATH, PAYLOAD_TYPE_TRACE };
static const uint8_t OTHER_TYPES[] = { PAYLOAD_TYPE_REQ, PAYLOAD_TYPE_RESPONSE, PAYLOAD_TYPE_ANON_REQ,
PAYLOAD_TYPE_GRP_DATA, PAYLOAD_TYPE_MULTIPART, PAYLOAD_TYPE_CONTROL,
PAYLOAD_TYPE_RAW_CUSTOM };
uint32_t msg_rx = sumByTypes(true, MSG_TYPES, 2), msg_tx = sumByTypes(false, MSG_TYPES, 2);
uint32_t adv_rx = sumByTypes(true, ADVERT_TYPES, 1), adv_tx = sumByTypes(false, ADVERT_TYPES, 1);
uint32_t rte_rx = sumByTypes(true, ROUTE_TYPES, 3), rte_tx = sumByTypes(false, ROUTE_TYPES, 3);
uint32_t oth_rx = sumByTypes(true, OTHER_TYPES, 7), oth_tx = sumByTypes(false, OTHER_TYPES, 7);
addRxTxRow("Total rx/tx", msg_rx + adv_rx + rte_rx + oth_rx, msg_tx + adv_tx + rte_tx + oth_tx);
addRxTxRow("Msg", msg_rx, msg_tx);
addRxTxRow("Advert", adv_rx, adv_tx);
addRxTxRow("Ack/Path", rte_rx, rte_tx);
addRxTxRow("Other", oth_rx, oth_tx);
uint32_t heap_free, heap_total;
DeviceDiag::getHeapStats(heap_free, heap_total);
if (heap_total > 0) snprintf(buf, sizeof(buf), "%lu/%luKB", (unsigned long)(heap_free / 1024), (unsigned long)(heap_total / 1024));
else strcpy(buf, "N/A");
addRow("Heap free", buf);
uint32_t stack_free = DeviceDiag::getStackFreeBytes();
snprintf(buf, sizeof(buf), "%luB", (unsigned long)stack_free);
addRow("Stack free", buf);
snprintf(buf, sizeof(buf), "%d dBm", (int)radio_driver.getNoiseFloor());
addRow("Noise floor", buf);
snprintf(buf, sizeof(buf), "%d/%.1f", (int)radio_driver.getLastRSSI(), radio_driver.getLastSNR());
addRow("RSSI/SNR", buf);
snprintf(buf, sizeof(buf), "%d", the_mesh.getPoolFreeCount());
addRow("Pool free", buf);
snprintf(buf, sizeof(buf), "%d", the_mesh.getOutboundQueueLen());
addRow("Queue", buf);
}
public:
DiagnosticsScreen(UITask* task) : _task(task) {}
int render(DisplayDriver& display) override {
buildRows();
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.drawCenteredHeader("DIAGNOSTICS");
const int item_h = display.lineStep();
const int start_y = display.listStart();
int visible = display.listVisible(item_h);
if (visible < 1) visible = 1;
int max_scroll = _row_count - visible;
if (max_scroll < 0) max_scroll = 0;
if (_scroll > max_scroll) _scroll = max_scroll;
if (_scroll < 0) _scroll = 0;
const int reserve = scrollIndicatorReserve(display, _row_count, visible);
for (int i = 0; i < visible && (_scroll + i) < _row_count; i++) {
const Row& r = _rows[_scroll + i];
int y = start_y + i * item_h;
display.setCursor(2, y);
display.print(r.label);
display.drawTextRightAlign(display.width() - reserve - 2, y, r.value);
}
drawScrollIndicator(display, start_y, visible * item_h, _row_count, visible, _scroll);
display.setColor(DisplayDriver::LIGHT);
return 1000; // stats refresh once a second — no need for a faster redraw
}
bool handleInput(char c) override {
if (c == KEY_UP) { if (_scroll > 0) _scroll--; return true; }
if (c == KEY_DOWN) { _scroll++; return true; } // clamped to max_scroll in render()
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU || c == KEY_ENTER) { _task->gotoToolsScreen(); return true; }
return false;
}
};

View File

@@ -7,7 +7,7 @@ class ToolsScreen : public UIScreen {
int _sel;
int _scroll = 0;
static const int ITEM_COUNT = 6;
static const int ITEM_COUNT = 7;
static const char* ITEMS[ITEM_COUNT];
public:
@@ -37,8 +37,9 @@ public:
if (_sel == 3) { _task->gotoAutoAdvertScreen(); return true; }
if (_sel == 4) { _task->gotoTrailScreen(); return true; }
if (_sel == 5) { _task->gotoCompassScreen(); return true; }
if (_sel == 6) { _task->gotoDiagnosticsScreen(); return true; }
}
return false;
}
};
const char* ToolsScreen::ITEMS[6] = { "Ringtone Editor", "Auto-Reply Bot", "Nearby Nodes", "Auto-Advert", "Trail", "Compass" };
const char* ToolsScreen::ITEMS[7] = { "Ringtone Editor", "Auto-Reply Bot", "Nearby Nodes", "Auto-Advert", "Trail", "Compass", "Diagnostics" };

View File

@@ -125,6 +125,7 @@ static const int QUICK_MSGS_MAX = 10;
#include "AutoAdvertScreen.h"
#include "TrailScreen.h"
#include "CompassScreen.h"
#include "DiagnosticsScreen.h"
#include "ToolsScreen.h"
#ifndef BATT_MIN_MILLIVOLTS
@@ -1198,6 +1199,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
auto_advert_screen = new AutoAdvertScreen(this, node_prefs);
trail_screen = new TrailScreen(this, &_trail);
compass_screen = new CompassScreen(this);
diag_screen = new DiagnosticsScreen(this);
applyBrightness();
applyFont();
applyRotation();
@@ -1244,6 +1246,10 @@ void UITask::gotoCompassScreen() {
setCurrScreen(compass_screen);
}
void UITask::gotoDiagnosticsScreen() {
setCurrScreen(diag_screen);
}
void UITask::gotoAutoAdvertScreen() {
((AutoAdvertScreen*)auto_advert_screen)->enter();
setCurrScreen(auto_advert_screen);

View File

@@ -79,6 +79,7 @@ class UITask : public AbstractUITask {
UIScreen* auto_advert_screen;
UIScreen* trail_screen;
UIScreen* compass_screen;
UIScreen* diag_screen;
UIScreen* curr;
CayenneLPP _dash_lpp;
TrailStore _trail;
@@ -152,6 +153,7 @@ public:
void gotoAutoAdvertScreen();
void gotoTrailScreen();
void gotoCompassScreen();
void gotoDiagnosticsScreen();
TrailStore& trail() { return _trail; }
WaypointStore& waypoints() { return _waypoints; }
// Shared on-screen keyboard — only one screen drives it at a time.

View File

@@ -19,6 +19,8 @@ namespace mesh {
void Dispatcher::begin() {
n_sent_flood = n_sent_direct = 0;
n_recv_flood = n_recv_direct = 0;
memset(n_sent_by_type, 0, sizeof(n_sent_by_type));
memset(n_recv_by_type, 0, sizeof(n_recv_by_type));
_err_flags = 0;
radio_nonrx_start = _ms->getMillis();
@@ -106,6 +108,7 @@ void Dispatcher::loop() {
_radio->onSendFinished();
logTx(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len);
n_sent_by_type[outbound->getPayloadType()]++;
if (outbound->isRouteFlood()) {
n_sent_flood++;
} else {
@@ -236,6 +239,7 @@ void Dispatcher::checkRecv() {
#endif
logRx(pkt, pkt->getRawLength(), score); // hook for custom logging
n_recv_by_type[pkt->getPayloadType()]++;
if (pkt->isRouteFlood()) {
n_recv_flood++;

View File

@@ -123,6 +123,7 @@ class Dispatcher {
bool prev_isrecv_mode;
uint32_t n_sent_flood, n_sent_direct;
uint32_t n_recv_flood, n_recv_direct;
uint32_t n_sent_by_type[16], n_recv_by_type[16]; // indexed by getPayloadType() (4-bit field)
unsigned long tx_budget_ms;
unsigned long last_budget_update;
unsigned long duty_cycle_window_ms;
@@ -184,8 +185,14 @@ public:
uint32_t getNumSentDirect() const { return n_sent_direct; }
uint32_t getNumRecvFlood() const { return n_recv_flood; }
uint32_t getNumRecvDirect() const { return n_recv_direct; }
uint32_t getNumSentByType(uint8_t payload_type) const { return n_sent_by_type[payload_type & 0x0F]; }
uint32_t getNumRecvByType(uint8_t payload_type) const { return n_recv_by_type[payload_type & 0x0F]; }
int getPoolFreeCount() const { return _mgr->getFreeCount(); }
int getOutboundQueueLen() const { return _mgr->getOutboundTotal(); }
void resetStats() {
n_sent_flood = n_sent_direct = n_recv_flood = n_recv_direct = 0;
memset(n_sent_by_type, 0, sizeof(n_sent_by_type));
memset(n_recv_by_type, 0, sizeof(n_recv_by_type));
_err_flags = 0;
}

View File

@@ -0,0 +1,31 @@
#include "DeviceDiag.h"
#if defined(NRF52_PLATFORM)
#include "FreeRTOS.h"
#include "task.h"
// Same linker symbols + _sbrk() the core's own new.cpp uses to implement
// malloc's heap (see framework-arduinoadafruitnrf52/cores/nRF5/new.cpp) —
// there's no separate "free heap" API on this core, so this mirrors it.
extern "C" char* _sbrk(int incr);
extern unsigned char __HeapBase[];
extern unsigned char __HeapLimit[];
void DeviceDiag::getHeapStats(uint32_t& free_bytes, uint32_t& total_bytes) {
total_bytes = (uint32_t)(__HeapLimit - __HeapBase);
uint32_t used = (uint32_t)((unsigned char*)_sbrk(0) - __HeapBase);
free_bytes = (used < total_bytes) ? (total_bytes - used) : 0;
}
uint32_t DeviceDiag::getStackFreeBytes() {
return (uint32_t)uxTaskGetStackHighWaterMark(NULL) * sizeof(StackType_t);
}
#else
void DeviceDiag::getHeapStats(uint32_t& free_bytes, uint32_t& total_bytes) {
free_bytes = 0; total_bytes = 0;
}
uint32_t DeviceDiag::getStackFreeBytes() { return 0; }
#endif

10
src/helpers/DeviceDiag.h Normal file
View File

@@ -0,0 +1,10 @@
#pragma once
#include <stdint.h>
// Cross-platform free-heap / stack-headroom estimate for the on-device
// diagnostics screen. A total_bytes of 0 means "not supported on this
// platform" — callers should display "N/A" rather than 0/0.
struct DeviceDiag {
static void getHeapStats(uint32_t& free_bytes, uint32_t& total_bytes);
static uint32_t getStackFreeBytes(); // current task's min-ever headroom; 0 = unsupported
};