From 327e659d51b0bbe927c77cecc3b948d1f28c1cc0 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:34:52 +0200 Subject: [PATCH] feat(ui): add on-device diagnostics screen (Tools > Diagnostics) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ui-new/DiagnosticsScreen.h | 137 ++++++++++++++++++ examples/companion_radio/ui-new/ToolsScreen.h | 5 +- examples/companion_radio/ui-new/UITask.cpp | 6 + examples/companion_radio/ui-new/UITask.h | 2 + src/Dispatcher.cpp | 4 + src/Dispatcher.h | 7 + src/helpers/DeviceDiag.cpp | 31 ++++ src/helpers/DeviceDiag.h | 10 ++ 8 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 examples/companion_radio/ui-new/DiagnosticsScreen.h create mode 100644 src/helpers/DeviceDiag.cpp create mode 100644 src/helpers/DeviceDiag.h diff --git a/examples/companion_radio/ui-new/DiagnosticsScreen.h b/examples/companion_radio/ui-new/DiagnosticsScreen.h new file mode 100644 index 00000000..185c4e77 --- /dev/null +++ b/examples/companion_radio/ui-new/DiagnosticsScreen.h @@ -0,0 +1,137 @@ +#pragma once +#include +#include +#include +#include +#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; + } +}; diff --git a/examples/companion_radio/ui-new/ToolsScreen.h b/examples/companion_radio/ui-new/ToolsScreen.h index 09bc7d5c..3b058fbf 100644 --- a/examples/companion_radio/ui-new/ToolsScreen.h +++ b/examples/companion_radio/ui-new/ToolsScreen.h @@ -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" }; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 5395b34c..2fb93413 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 3f92190c..be2b0264 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -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. diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 9d7a1113..375debe0 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -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++; diff --git a/src/Dispatcher.h b/src/Dispatcher.h index dd032f13..0128c57c 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -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; } diff --git a/src/helpers/DeviceDiag.cpp b/src/helpers/DeviceDiag.cpp new file mode 100644 index 00000000..bfee9186 --- /dev/null +++ b/src/helpers/DeviceDiag.cpp @@ -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 diff --git a/src/helpers/DeviceDiag.h b/src/helpers/DeviceDiag.h new file mode 100644 index 00000000..54a31e46 --- /dev/null +++ b/src/helpers/DeviceDiag.h @@ -0,0 +1,10 @@ +#pragma once +#include + +// 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 +};