From fcd92e985f8cd9fbcba38e4927874dcae7455e21 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 28 Aug 2026 13:39:16 -0700 Subject: [PATCH] feat(display): add R8 observer TFT dashboard, touch toggle and display.timeout Replace the sparse Heltec V4 R8 observer home screen with a padded dark analytics dashboard, add manual display control, and make blanking a runtime setting. Dashboard (DISPLAY_ACTIVITY_DASHBOARD, the four R8 TFT observer envs): - RadioActivityWindow: 20 one-minute buckets of valid RX packets, no heap. The caller's 32-bit millis() is extended to a monotonic 64-bit clock, so nothing downstream has a rollover case; an always-on node passes 2^32 ms after ~49.7 days, which would otherwise re-enter warm-up and divide 20 minutes of traffic by seconds. Rates use 19 whole minutes plus the elapsed part of the current one rather than a fixed 1200 s. - ObserverDashboard: header, radio strip, headline totals, a 20-bar packets-per-minute graph and RF/status footers, with separate portrait and landscape layouts. A text row is a fixed 16 px, which is 3.2 logical units in portrait but 4.27 in landscape, so one shared grid would overlap. Text is trimmed by character budget, not measured width: getTextWidth() reports an over-long string at the portrait driver's fallback scale, so DisplayDriver::drawTextEllipsized() under-trims and the row renders at half height. - Six per-row signatures computed from what is actually drawn, so only the rows whose pixels changed repaint. No startFrame(), no whole-screen clear. Link state moved out of the full-frame signature, so a DHCP renewal or WiFi flap repaints one footer row instead of the panel. - Dark theme by retuning the UIColor statics at runtime, which needs no display-driver edit and carries boot, setup, reboot and power-off with it. Touch and button (DISPLAY_TOUCH_TOGGLE): - CHSC6X at I2C 0x2E, polled; TP_INT is unusable (optional R13, and GPIO 43 is U0TXD). The point-count byte is tested against a valid count, never against non-zero: an idle read returns 0xFF, which reads as a finger held down forever and latches the tap detector after one event. - turnOff() no longer parks PIN_TFT_RST low on this board. GPIO 21 is a shared LCD_RST/TP_RST net, so doing that held the touch controller in reset for as long as the display was off. Verified against Heltec's expansion-board and mainboard schematics and the V4-R8 datasheet pinout, which also correct the pin comment in HeltecV4R8Board.cpp. - The USER button click now toggles the display too; it previously did nothing whenever the display was already on. display.timeout: - `set display.timeout ` / `get display.timeout`, 0 = stay on, 60 s default, 3600 max. Read live, so a change applies without a reboot and restarts the countdown rather than firing on the old deadline. - Stored in MQTTPrefs (/mqtt.json), keeping NodePrefs aligned with upstream. Runtime-only: LegacyV1MQTTPrefs and the four frozen binary payload sizes are unchanged. No JSON format-version bump - the loader skips keys no def() claims, so older firmware reads newer files and this firmware reads older ones with the default applied. Both directions are covered by tests. - Joins the observer atomic-setter contract, so a failed save rolls the live value back instead of only claiming to. New periodic work uses a wrap-safe deadline check; `millis() >= deadline` fires every loop for a whole interval before each rollover. Adds test_radio_activity_window, test_observer_dashboard (driving the real renderer against a recording DisplayDriver in both orientation profiles) and test_touch_tap_detector. 440 native cases pass. --- examples/simple_repeater/MyMesh.cpp | 5 + examples/simple_repeater/MyMesh.h | 15 + examples/simple_repeater/UITask.cpp | 155 ++++- examples/simple_repeater/UITask.h | 48 ++ examples/simple_repeater/main.cpp | 6 + examples/simple_room_server/MyMesh.cpp | 5 + examples/simple_room_server/MyMesh.h | 15 + examples/simple_room_server/UITask.cpp | 155 ++++- examples/simple_room_server/UITask.h | 48 ++ examples/simple_room_server/main.cpp | 6 + src/helpers/CommonCLI_Observer.cpp | 29 +- src/helpers/MQTTDefaults.h | 2 + src/helpers/MQTTPrefsSerializer.h | 23 +- src/helpers/MQTTPrefsStorage.h | 8 + src/helpers/RadioActivityWindow.h | 207 +++++++ src/helpers/ui/CHSC6XTouch.h | 108 ++++ src/helpers/ui/ObserverDashboard.h | 524 ++++++++++++++++ src/helpers/ui/ST7789LCDDisplay.cpp | 7 +- src/helpers/ui/TouchTapDetector.h | 61 ++ test/README.md | 3 + .../test_mqtt_prefs_serializer.cpp | 67 ++ test/test_observer_dashboard/MockDisplay.h | 122 ++++ .../test_observer_dashboard.cpp | 581 ++++++++++++++++++ .../test_radio_activity_window.cpp | 376 ++++++++++++ .../test_touch_tap_detector.cpp | 123 ++++ variants/heltec_v4_r8/HeltecV4R8Board.cpp | 19 +- variants/heltec_v4_r8/platformio.ini | 16 + 27 files changed, 2709 insertions(+), 25 deletions(-) create mode 100644 src/helpers/RadioActivityWindow.h create mode 100644 src/helpers/ui/CHSC6XTouch.h create mode 100644 src/helpers/ui/ObserverDashboard.h create mode 100644 src/helpers/ui/TouchTapDetector.h create mode 100644 test/test_observer_dashboard/MockDisplay.h create mode 100644 test/test_observer_dashboard/test_observer_dashboard.cpp create mode 100644 test/test_radio_activity_window/test_radio_activity_window.cpp create mode 100644 test/test_touch_tap_detector/test_touch_tap_detector.cpp diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 859076e9..6c96112a 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -540,6 +540,11 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { } void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { +#ifdef DISPLAY_ACTIVITY_DASHBOARD + // Valid parsed RF packet: the only event the dashboard's window counts. + _activity.recordPacket(millis(), (uint16_t)len, _radio->getEstAirtimeFor(len), + (int8_t)(pkt->getSNR() * 4.0f), (int16_t)_radio->getLastRSSI()); +#endif #ifdef WITH_MQTT_BRIDGE // MQTT bridge: always feed RX packets — bridge decides based on mqtt.rx setting if (bridge) bridge->onPacketReceived(pkt); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index e4c3a3f7..aabd605a 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -35,6 +35,10 @@ #include "helpers/SNMPAgent.h" #endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD +#include +#endif + #include #include #include @@ -101,6 +105,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks uint64_t uptime_millis; unsigned long next_local_advert, next_flood_advert; bool _logging; +#ifdef DISPLAY_ACTIVITY_DASHBOARD + RadioActivityWindow _activity; // rolling RF receive window, for the TFT dashboard +#endif NodePrefs _prefs; ClientACL acl; CommonCLI _cli; @@ -282,6 +289,14 @@ public: return &_prefs; } +#ifdef DISPLAY_ACTIVITY_DASHBOARD + RadioActivityWindow* getActivityWindow() { return &_activity; } +#endif + +#ifdef WITH_MQTT_BRIDGE + MQTTPrefs* getObserverPrefs() { return _cli.getObserverPrefs(); } +#endif + void savePrefs() override { _cli.savePrefs(_fs); } diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index e8f48ed3..ce46b9c7 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -16,7 +16,31 @@ #include // defines WITH_WEBCONFIG on ESP32 #endif -#define AUTO_OFF_MILLIS 20000 // 20 seconds +#ifndef AUTO_OFF_MILLIS +#define AUTO_OFF_MILLIS 20000 // 20 seconds; 0 keeps the screen on +#endif + +#ifdef DISPLAY_TOUCH_TOGGLE +#define TOUCH_POLL_MILLIS 50 +#endif + +// Wrap-safe deadline test. `millis() >= deadline` fires early for the whole +// interval before a rollover, because the deadline has already wrapped to a +// small value while millis() is still near UINT32_MAX; the signed difference +// stays correct across it. +static inline bool millisReached(unsigned long now, unsigned long deadline) { + return (int32_t)((uint32_t)now - (uint32_t)deadline) >= 0; +} + +// `display.timeout` when the observer prefs are available, otherwise the +// compiled-in default. Read on every use so a `set display.timeout` takes +// effect immediately. +unsigned long UITask::displayTimeoutMillis() const { +#ifdef WITH_MQTT_BRIDGE + if (_observer_prefs) return (unsigned long)_observer_prefs->display_timeout_secs * 1000UL; +#endif + return AUTO_OFF_MILLIS; +} #define BOOT_SCREEN_MILLIS 4000 // 4 seconds #define POWEROFF_DELAY 3000 @@ -40,10 +64,17 @@ static const uint8_t meshcore_logo [] PROGMEM = { void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) { _prevBtnState = HIGH; - _auto_off = millis() + AUTO_OFF_MILLIS; + _timeout_seen = displayTimeoutMillis(); + _auto_off = millis() + displayTimeoutMillis(); _started_at = millis(); _node_prefs = node_prefs; +#ifdef DISPLAY_ACTIVITY_DASHBOARD + ObserverDashboard::applyDarkPalette(); // retunes UIColor for this target only +#endif _display->turnOn(); +#ifdef DISPLAY_TOUCH_TOGGLE + _touch.begin(); +#endif #ifdef DISPLAY_REDRAW_ON_CHANGE _frame_valid = false; #endif @@ -67,6 +98,9 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi void UITask::renderCurrScreen() { char tmp[80]; +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif if (millis() < _started_at + BOOT_SCREEN_MILLIS) { // boot screen // meshcore logo _display->setColor(UIColor::corp_blue); @@ -140,6 +174,10 @@ void UITask::renderCurrScreen() { _display->print(wc_ip); return; } +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + renderDashboard(); + return; #endif // node name _display->setCursor(0, 0); @@ -204,7 +242,7 @@ uint32_t UITask::getFrameSignature() { snprintf(tmp, sizeof(tmp), "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); signature = DisplayFrameSignature::append(signature, tmp); -#ifdef WITH_MQTT_BRIDGE +#if defined(WITH_MQTT_BRIDGE) && !defined(DISPLAY_ACTIVITY_DASHBOARD) if (WiFi.status() == WL_CONNECTED) { IPAddress ip = WiFi.localIP(); snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); @@ -218,19 +256,94 @@ uint32_t UITask::getFrameSignature() { } #endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD +#define ACTIVITY_REFRESH_MILLIS 5000 + +bool UITask::buildDashboardContext(ObserverDashboard::Context* ctx) { + if (_node_prefs == NULL) return false; + ctx->node_name = _node_prefs->node_name; + ctx->role_label = "REPEATER"; + ctx->freq = _node_prefs->freq; + ctx->sf = _node_prefs->sf; + ctx->bw = _node_prefs->bw; +#ifdef WITH_MQTT_BRIDGE + ctx->link_up = (WiFi.status() == WL_CONNECTED); +#else + ctx->link_up = false; +#endif + return true; +} + +void UITask::renderDashboard() { + ObserverDashboard::Context ctx; + if (!buildDashboardContext(&ctx)) return; + + RadioActivitySnapshot snap; + if (_activity) { + _activity->snapshot(millis(), &snap); + } else { + memset(&snap, 0, sizeof(snap)); + } + + const ObserverDashboard::Layout& layout = ObserverDashboard::activeLayout(); + ObserverDashboard::drawFull(*_display, layout, ctx, snap); + ObserverDashboard::allRowSignatures(layout, ctx, snap, _row_signatures); + _rows_valid = true; + _next_activity = millis() + ACTIVITY_REFRESH_MILLIS; +} + +// Repaints just the analytics rows whose contents moved. No startFrame(), so +// the header, the radio strip and the rest of the panel are never cleared. +void UITask::updateActivityRows() { + if (!_rows_valid || _activity == NULL) return; // not showing the dashboard + + ObserverDashboard::Context ctx; + if (!buildDashboardContext(&ctx)) return; + + RadioActivitySnapshot snap; + _activity->snapshot(millis(), &snap); + ObserverDashboard::drawChangedRows(*_display, ObserverDashboard::activeLayout(), ctx, snap, + _row_signatures); +} +#endif + +#ifdef DISPLAY_TOUCH_TOGGLE +void UITask::toggleDisplay() { + if (_display->isOn()) { + _display->turnOff(); + } else { + _display->turnOn(); + } +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; // wake draws one complete current frame +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif + _auto_off = millis() + displayTimeoutMillis(); +} +#endif + void UITask::loop() { #if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) int ev = user_btn.check(); if (ev == BUTTON_EVENT_CLICK) { +#ifdef DISPLAY_TOUCH_TOGGLE + toggleDisplay(); // same action as tapping the panel +#else if (_display->isOn()) { // TODO: any action ? } else { _display->turnOn(); #ifdef DISPLAY_REDRAW_ON_CHANGE _frame_valid = false; +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; #endif } - _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer + _auto_off = millis() + displayTimeoutMillis(); // extend auto-off timer +#endif } else if (ev == BUTTON_EVENT_LONG_PRESS) { _display->turnOn(); Serial.println("Powering Off"); @@ -246,9 +359,22 @@ void UITask::loop() { _display->turnOn(); #ifdef DISPLAY_REDRAW_ON_CHANGE _frame_valid = false; +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; #endif } - _auto_off = millis() + AUTO_OFF_MILLIS; + _auto_off = millis() + displayTimeoutMillis(); + } +#endif + +#ifdef DISPLAY_TOUCH_TOGGLE + { + unsigned long now = millis(); + if (millisReached(now, _next_touch)) { + _next_touch = now + TOUCH_POLL_MILLIS; + if (_touch.checkTap(now)) toggleDisplay(); + } } #endif @@ -268,13 +394,30 @@ void UITask::loop() { _frame_valid = true; #endif } +#ifdef DISPLAY_ACTIVITY_DASHBOARD + else if (millisReached(millis(), _next_activity)) { + updateActivityRows(); + _next_activity = millis() + ACTIVITY_REFRESH_MILLIS; + } +#endif _next_refresh = millis() + 1000; // check for visible changes every second } - if (millis() > _auto_off) { + // `_auto_off` is only armed on activity, so a timeout changed at runtime has + // to restart the countdown here - otherwise 0 -> 60 blanks instantly off a + // boot-time deadline, and 60 -> 3600 still blanks at the old 60 s mark. + unsigned long timeout = displayTimeoutMillis(); + if (timeout != _timeout_seen) { + _timeout_seen = timeout; + _auto_off = millis() + timeout; + } + if (timeout > 0 && millisReached(millis(), _auto_off)) { _display->turnOff(); #ifdef DISPLAY_REDRAW_ON_CHANGE _frame_valid = false; +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; #endif } } diff --git a/examples/simple_repeater/UITask.h b/examples/simple_repeater/UITask.h index f0ce4221..ad6c5de1 100644 --- a/examples/simple_repeater/UITask.h +++ b/examples/simple_repeater/UITask.h @@ -3,6 +3,19 @@ #include #include +#ifdef DISPLAY_ACTIVITY_DASHBOARD + #ifndef DISPLAY_REDRAW_ON_CHANGE + #error "DISPLAY_ACTIVITY_DASHBOARD needs DISPLAY_REDRAW_ON_CHANGE: without it every frame clears the whole screen" + #endif + #include + #include + +#endif + +#ifdef DISPLAY_TOUCH_TOGGLE + #include +#endif + class UITask { mesh::MainBoard* _board; DisplayDriver* _display; @@ -20,10 +33,45 @@ class UITask { uint32_t getFrameSignature(); #endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + RadioActivityWindow* _activity = NULL; + unsigned long _next_activity = 0; + uint32_t _row_signatures[ObserverDashboard::ROW_COUNT] = {0}; + bool _rows_valid = false; // true only while the dashboard is the drawn screen + + bool buildDashboardContext(ObserverDashboard::Context* ctx); + void renderDashboard(); + void updateActivityRows(); +#endif + +#ifdef DISPLAY_TOUCH_TOGGLE + CHSC6XTouch _touch; + unsigned long _next_touch = 0; + + void toggleDisplay(); +#endif + +#ifdef WITH_MQTT_BRIDGE + MQTTPrefs* _observer_prefs = NULL; +#endif + unsigned long _timeout_seen = 0; // to notice a live `display.timeout` change + + unsigned long displayTimeoutMillis() const; + void renderCurrScreen(); public: UITask(mesh::MainBoard& board, DisplayDriver& display) : _board(&board), _display(&display) { _next_read = _next_refresh = 0; } void begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version); +#ifdef WITH_MQTT_BRIDGE + // Supplies `display.timeout`, which is read live so a config change applies + // without a reboot. Call before begin(). + void setObserverPrefs(MQTTPrefs* prefs) { _observer_prefs = prefs; } +#endif + +#ifdef DISPLAY_ACTIVITY_DASHBOARD + void setActivityWindow(RadioActivityWindow* activity) { _activity = activity; } +#endif + void loop(); }; diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 06873d31..fa56772a 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -115,7 +115,13 @@ void setup() { #ifdef DISPLAY_CLASS if (display_ready) { +#ifdef WITH_MQTT_BRIDGE + ui_task.setObserverPrefs(the_mesh.getObserverPrefs()); +#endif ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION); +#ifdef DISPLAY_ACTIVITY_DASHBOARD + ui_task.setActivityWindow(the_mesh.getActivityWindow()); +#endif } #endif diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 02efbd43..a89dca23 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -246,6 +246,11 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { } void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { +#ifdef DISPLAY_ACTIVITY_DASHBOARD + // Valid parsed RF packet: the only event the dashboard's window counts. + _activity.recordPacket(millis(), (uint16_t)len, _radio->getEstAirtimeFor(len), + (int8_t)(pkt->getSNR() * 4.0f), (int16_t)_radio->getLastRSSI()); +#endif #ifdef WITH_MQTT_BRIDGE // MQTT bridge: always feed RX packets — bridge decides based on mqtt.rx setting if (_prefs.bridge_enabled && bridge) bridge->onPacketReceived(pkt); diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 65de77ed..47f2f68d 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -15,6 +15,10 @@ #include #include #include +#ifdef DISPLAY_ACTIVITY_DASHBOARD +#include +#endif + #include #include #include @@ -117,6 +121,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks uint64_t uptime_millis; unsigned long next_local_advert, next_flood_advert; bool _logging; +#ifdef DISPLAY_ACTIVITY_DASHBOARD + RadioActivityWindow _activity; // rolling RF receive window, for the TFT dashboard +#endif bool region_load_active; NodePrefs _prefs; TransportKeyStore key_store; @@ -286,6 +293,14 @@ public: return &_prefs; } +#ifdef DISPLAY_ACTIVITY_DASHBOARD + RadioActivityWindow* getActivityWindow() { return &_activity; } +#endif + +#ifdef WITH_MQTT_BRIDGE + MQTTPrefs* getObserverPrefs() { return _cli.getObserverPrefs(); } +#endif + void savePrefs() override { _cli.savePrefs(_fs); } diff --git a/examples/simple_room_server/UITask.cpp b/examples/simple_room_server/UITask.cpp index 884ab528..3f124cb1 100644 --- a/examples/simple_room_server/UITask.cpp +++ b/examples/simple_room_server/UITask.cpp @@ -15,7 +15,31 @@ #include // defines WITH_WEBCONFIG on ESP32 #endif -#define AUTO_OFF_MILLIS 20000 // 20 seconds +#ifndef AUTO_OFF_MILLIS +#define AUTO_OFF_MILLIS 20000 // 20 seconds; 0 keeps the screen on +#endif + +#ifdef DISPLAY_TOUCH_TOGGLE +#define TOUCH_POLL_MILLIS 50 +#endif + +// Wrap-safe deadline test. `millis() >= deadline` fires early for the whole +// interval before a rollover, because the deadline has already wrapped to a +// small value while millis() is still near UINT32_MAX; the signed difference +// stays correct across it. +static inline bool millisReached(unsigned long now, unsigned long deadline) { + return (int32_t)((uint32_t)now - (uint32_t)deadline) >= 0; +} + +// `display.timeout` when the observer prefs are available, otherwise the +// compiled-in default. Read on every use so a `set display.timeout` takes +// effect immediately. +unsigned long UITask::displayTimeoutMillis() const { +#ifdef WITH_MQTT_BRIDGE + if (_observer_prefs) return (unsigned long)_observer_prefs->display_timeout_secs * 1000UL; +#endif + return AUTO_OFF_MILLIS; +} #define BOOT_SCREEN_MILLIS 4000 // 4 seconds // 'meshcore', 128x13px @@ -37,9 +61,16 @@ static const uint8_t meshcore_logo [] PROGMEM = { void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) { _prevBtnState = HIGH; - _auto_off = millis() + AUTO_OFF_MILLIS; + _timeout_seen = displayTimeoutMillis(); + _auto_off = millis() + displayTimeoutMillis(); _node_prefs = node_prefs; +#ifdef DISPLAY_ACTIVITY_DASHBOARD + ObserverDashboard::applyDarkPalette(); // retunes UIColor for this target only +#endif _display->turnOn(); +#ifdef DISPLAY_TOUCH_TOGGLE + _touch.begin(); +#endif #ifdef DISPLAY_REDRAW_ON_CHANGE _frame_valid = false; #endif @@ -59,6 +90,9 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi void UITask::renderCurrScreen() { char tmp[80]; +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif if (millis() < BOOT_SCREEN_MILLIS) { // boot screen // meshcore logo _display->setColor(UIColor::corp_blue); @@ -121,6 +155,10 @@ void UITask::renderCurrScreen() { _display->print(wc_ip); return; } +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + renderDashboard(); + return; #endif // node name _display->setCursor(0, 0); @@ -181,7 +219,7 @@ uint32_t UITask::getFrameSignature() { snprintf(tmp, sizeof(tmp), "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); signature = DisplayFrameSignature::append(signature, tmp); -#ifdef WITH_MQTT_BRIDGE +#if defined(WITH_MQTT_BRIDGE) && !defined(DISPLAY_ACTIVITY_DASHBOARD) if (WiFi.status() == WL_CONNECTED) { IPAddress ip = WiFi.localIP(); snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); @@ -195,21 +233,96 @@ uint32_t UITask::getFrameSignature() { } #endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD +#define ACTIVITY_REFRESH_MILLIS 5000 + +bool UITask::buildDashboardContext(ObserverDashboard::Context* ctx) { + if (_node_prefs == NULL) return false; + ctx->node_name = _node_prefs->node_name; + ctx->role_label = "ROOM SERVER"; + ctx->freq = _node_prefs->freq; + ctx->sf = _node_prefs->sf; + ctx->bw = _node_prefs->bw; +#ifdef WITH_MQTT_BRIDGE + ctx->link_up = (WiFi.status() == WL_CONNECTED); +#else + ctx->link_up = false; +#endif + return true; +} + +void UITask::renderDashboard() { + ObserverDashboard::Context ctx; + if (!buildDashboardContext(&ctx)) return; + + RadioActivitySnapshot snap; + if (_activity) { + _activity->snapshot(millis(), &snap); + } else { + memset(&snap, 0, sizeof(snap)); + } + + const ObserverDashboard::Layout& layout = ObserverDashboard::activeLayout(); + ObserverDashboard::drawFull(*_display, layout, ctx, snap); + ObserverDashboard::allRowSignatures(layout, ctx, snap, _row_signatures); + _rows_valid = true; + _next_activity = millis() + ACTIVITY_REFRESH_MILLIS; +} + +// Repaints just the analytics rows whose contents moved. No startFrame(), so +// the header, the radio strip and the rest of the panel are never cleared. +void UITask::updateActivityRows() { + if (!_rows_valid || _activity == NULL) return; // not showing the dashboard + + ObserverDashboard::Context ctx; + if (!buildDashboardContext(&ctx)) return; + + RadioActivitySnapshot snap; + _activity->snapshot(millis(), &snap); + ObserverDashboard::drawChangedRows(*_display, ObserverDashboard::activeLayout(), ctx, snap, + _row_signatures); +} +#endif + +#ifdef DISPLAY_TOUCH_TOGGLE +void UITask::toggleDisplay() { + if (_display->isOn()) { + _display->turnOff(); + } else { + _display->turnOn(); + } +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; // wake draws one complete current frame +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif + _auto_off = millis() + displayTimeoutMillis(); +} +#endif + void UITask::loop() { #ifdef PIN_USER_BTN if (millis() >= _next_read) { int btnState = digitalRead(PIN_USER_BTN); if (btnState != _prevBtnState) { if (btnState == USER_BTN_PRESSED) { // pressed? +#ifdef DISPLAY_TOUCH_TOGGLE + toggleDisplay(); // same action as tapping the panel +#else if (_display->isOn()) { // TODO: any action ? } else { _display->turnOn(); #ifdef DISPLAY_REDRAW_ON_CHANGE _frame_valid = false; +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; #endif } - _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer + _auto_off = millis() + displayTimeoutMillis(); // extend auto-off timer +#endif } _prevBtnState = btnState; } @@ -225,9 +338,22 @@ void UITask::loop() { _display->turnOn(); #ifdef DISPLAY_REDRAW_ON_CHANGE _frame_valid = false; +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; #endif } - _auto_off = millis() + AUTO_OFF_MILLIS; + _auto_off = millis() + displayTimeoutMillis(); + } +#endif + +#ifdef DISPLAY_TOUCH_TOGGLE + { + unsigned long now = millis(); + if (millisReached(now, _next_touch)) { + _next_touch = now + TOUCH_POLL_MILLIS; + if (_touch.checkTap(now)) toggleDisplay(); + } } #endif @@ -247,13 +373,30 @@ void UITask::loop() { _frame_valid = true; #endif } +#ifdef DISPLAY_ACTIVITY_DASHBOARD + else if (millisReached(millis(), _next_activity)) { + updateActivityRows(); + _next_activity = millis() + ACTIVITY_REFRESH_MILLIS; + } +#endif _next_refresh = millis() + 1000; // check for visible changes every second } - if (millis() > _auto_off) { + // `_auto_off` is only armed on activity, so a timeout changed at runtime has + // to restart the countdown here - otherwise 0 -> 60 blanks instantly off a + // boot-time deadline, and 60 -> 3600 still blanks at the old 60 s mark. + unsigned long timeout = displayTimeoutMillis(); + if (timeout != _timeout_seen) { + _timeout_seen = timeout; + _auto_off = millis() + timeout; + } + if (timeout > 0 && millisReached(millis(), _auto_off)) { _display->turnOff(); #ifdef DISPLAY_REDRAW_ON_CHANGE _frame_valid = false; +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; #endif } } diff --git a/examples/simple_room_server/UITask.h b/examples/simple_room_server/UITask.h index 7ef88778..37005e18 100644 --- a/examples/simple_room_server/UITask.h +++ b/examples/simple_room_server/UITask.h @@ -3,6 +3,19 @@ #include #include +#ifdef DISPLAY_ACTIVITY_DASHBOARD + #ifndef DISPLAY_REDRAW_ON_CHANGE + #error "DISPLAY_ACTIVITY_DASHBOARD needs DISPLAY_REDRAW_ON_CHANGE: without it every frame clears the whole screen" + #endif + #include + #include + +#endif + +#ifdef DISPLAY_TOUCH_TOGGLE + #include +#endif + class UITask { DisplayDriver* _display; unsigned long _next_read, _next_refresh, _auto_off; @@ -17,10 +30,45 @@ class UITask { uint32_t getFrameSignature(); #endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + RadioActivityWindow* _activity = NULL; + unsigned long _next_activity = 0; + uint32_t _row_signatures[ObserverDashboard::ROW_COUNT] = {0}; + bool _rows_valid = false; // true only while the dashboard is the drawn screen + + bool buildDashboardContext(ObserverDashboard::Context* ctx); + void renderDashboard(); + void updateActivityRows(); +#endif + +#ifdef DISPLAY_TOUCH_TOGGLE + CHSC6XTouch _touch; + unsigned long _next_touch = 0; + + void toggleDisplay(); +#endif + +#ifdef WITH_MQTT_BRIDGE + MQTTPrefs* _observer_prefs = NULL; +#endif + unsigned long _timeout_seen = 0; // to notice a live `display.timeout` change + + unsigned long displayTimeoutMillis() const; + void renderCurrScreen(); public: UITask(DisplayDriver& display) : _display(&display) { _next_read = _next_refresh = 0; } void begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version); +#ifdef WITH_MQTT_BRIDGE + // Supplies `display.timeout`, which is read live so a config change applies + // without a reboot. Call before begin(). + void setObserverPrefs(MQTTPrefs* prefs) { _observer_prefs = prefs; } +#endif + +#ifdef DISPLAY_ACTIVITY_DASHBOARD + void setActivityWindow(RadioActivityWindow* activity) { _activity = activity; } +#endif + void loop(); }; diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index 6b5f74a9..48f48799 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -91,7 +91,13 @@ void setup() { #ifdef DISPLAY_CLASS if (display_ready) { +#ifdef WITH_MQTT_BRIDGE + ui_task.setObserverPrefs(the_mesh.getObserverPrefs()); +#endif ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION); +#ifdef DISPLAY_ACTIVITY_DASHBOARD + ui_task.setActivityWindow(the_mesh.getActivityWindow()); +#endif } #endif diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index c6fcb606..4cf66845 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -116,7 +116,8 @@ static bool isObserverPrefsSetCommand(const char* config) { strncmp(config, "mqtt", 4) == 0 || strncmp(config, "wifi.", 5) == 0 || strncmp(config, "timezone", 8) == 0 || - strncmp(config, "alert", 5) == 0; + strncmp(config, "alert", 5) == 0 || + strncmp(config, "display.", 8) == 0; } // Keep observer setters atomic from the caller's perspective. The live object @@ -265,6 +266,30 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf } } } + } else if (memcmp(config, "display.timeout ", 16) == 0) { + const char* val = &config[16]; + bool all_digits = (*val != '\0'); + for (const char* sp = val; *sp; sp++) { + if (*sp < '0' || *sp > '9') { all_digits = false; break; } + } + if (*val == '\0') { + strcpy(reply, "Error: missing display.timeout seconds"); + } else if (!all_digits) { + sprintf(reply, "Error: display.timeout must be an integer 0-%d", DISPLAY_TIMEOUT_MAX_SECS); + } else { + long secs = atol(val); + if (secs > DISPLAY_TIMEOUT_MAX_SECS) { + sprintf(reply, "Error: display.timeout must be 0-%d seconds", DISPLAY_TIMEOUT_MAX_SECS); + } else { + _mqtt_prefs.display_timeout_secs = (uint16_t)secs; + if (!persistObserverPrefs(reply)) return true; + if (secs == 0) { + strcpy(reply, "OK - display stays on"); + } else { + sprintf(reply, "OK - display off after %ld s", secs); + } + } + } #ifdef WITH_MQTT_BRIDGE } else if (strcmp(config, "mqtt.origin") == 0) { _mqtt_prefs.mqtt_origin[0] = '\0'; @@ -860,6 +885,8 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf strcpy(reply, _mqtt_prefs.snmp_enabled ? "> on" : "> off"); } else if (memcmp(config, "radio.watchdog", 14) == 0) { sprintf(reply, "> %d", (uint32_t)_mqtt_prefs.radio_watchdog_minutes); + } else if (memcmp(config, "display.timeout", 15) == 0) { + sprintf(reply, "> %d", (uint32_t)_mqtt_prefs.display_timeout_secs); #ifdef WITH_MQTT_BRIDGE } else if (memcmp(config, "mqtt.origin", 11) == 0) { char effective_origin[32]; diff --git a/src/helpers/MQTTDefaults.h b/src/helpers/MQTTDefaults.h index 21c53bc4..70140133 100644 --- a/src/helpers/MQTTDefaults.h +++ b/src/helpers/MQTTDefaults.h @@ -109,6 +109,8 @@ static inline void applyMQTTDefaults(MQTTPrefs* prefs) { // (not 0) so an in-lineage upgrade from a pre-neighbors payload is sane. prefs->mqtt_neighbors_enabled = 0; prefs->mqtt_neighbors_interval = MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS; + + prefs->display_timeout_secs = DISPLAY_TIMEOUT_DEFAULT_SECS; } #endif // WITH_MQTT_BRIDGE diff --git a/src/helpers/MQTTPrefsSerializer.h b/src/helpers/MQTTPrefsSerializer.h index 4d182c44..42e56477 100644 --- a/src/helpers/MQTTPrefsSerializer.h +++ b/src/helpers/MQTTPrefsSerializer.h @@ -366,6 +366,24 @@ class MQTTPrefsSerializer : public ConfigSerializer { } }; + class DisplayPrefs : public ConfigSerializer { + MQTTPrefs* _prefs; + int32_t _timeout_s; + bool _seen_timeout = false; + protected: + void structure() override { defStrict("timeout_s", _timeout_s, _seen_timeout); } + public: + explicit DisplayPrefs(MQTTPrefs* prefs) + : _prefs(prefs), _timeout_s(prefs->display_timeout_secs) {} + void apply(bool* repaired) { + if (_timeout_s < 0 || _timeout_s > DISPLAY_TIMEOUT_MAX_SECS) { + _timeout_s = DISPLAY_TIMEOUT_DEFAULT_SECS; + *repaired = true; + } + _prefs->display_timeout_secs = static_cast(_timeout_s); + } + }; + MQTTPrefs* _prefs; int32_t _version = MQTT_PREFS_JSON_FORMAT_VERSION; bool _seen_version = false; @@ -375,6 +393,7 @@ class MQTTPrefsSerializer : public ConfigSerializer { SnmpPrefs _snmp; RadioPrefs _radio; AlertPrefs _alert; + DisplayPrefs _display; protected: void structure() override { @@ -385,6 +404,7 @@ protected: def("snmp", _snmp); def("radio", _radio); def("alert", _alert); + def("display", _display); } public: @@ -392,7 +412,7 @@ public: : _prefs(prefs), _wifi(prefs), _time(prefs, repair_defaults ? repair_defaults : prefs), _mqtt(prefs, repair_defaults ? repair_defaults : prefs), _snmp(prefs), - _radio(prefs), _alert(prefs) {} + _radio(prefs), _alert(prefs), _display(prefs) {} bool hasSupportedVersion() const { return _seen_version && _version == MQTT_PREFS_JSON_FORMAT_VERSION; @@ -410,6 +430,7 @@ public: _snmp.apply(repaired); _radio.apply(repaired); _alert.apply(repaired); + _display.apply(repaired); return true; } diff --git a/src/helpers/MQTTPrefsStorage.h b/src/helpers/MQTTPrefsStorage.h index 0bc0867e..1ee4a99f 100644 --- a/src/helpers/MQTTPrefsStorage.h +++ b/src/helpers/MQTTPrefsStorage.h @@ -117,8 +117,16 @@ struct MQTTPrefs { // Per-slot payload-type allow masks. Bit N controls MeshCore packet type N // for both packets and raw MQTT topics. uint16_t mqtt_slot_packet_filter[MQTT_PREFS_SLOT_COUNT]; + + // Seconds of inactivity before the display blanks; 0 keeps it lit. Runtime + // only - deliberately absent from LegacyV1MQTTPrefs, so the frozen binary + // layout and its four payload sizes are unchanged. + uint16_t display_timeout_secs; }; +static const uint16_t DISPLAY_TIMEOUT_DEFAULT_SECS = 60; +static const uint16_t DISPLAY_TIMEOUT_MAX_SECS = 3600; + // Frozen payload written by the version-1 binary format. Keep this distinct // from the runtime MQTTPrefs type: JSON persistence must not make the runtime // object's padding or member order an on-flash ABI again. Legacy decoding reads diff --git a/src/helpers/RadioActivityWindow.h b/src/helpers/RadioActivityWindow.h new file mode 100644 index 00000000..36032b75 --- /dev/null +++ b/src/helpers/RadioActivityWindow.h @@ -0,0 +1,207 @@ +#pragma once + +#include +#include + +// Fixed-memory rolling window of RF receive activity, bucketed by minute. +// +// Pure logic: no Arduino, radio, display or role headers. Callers supply a +// millisecond counter and the per-packet values. +// +// The caller's 32-bit millis() is extended to a monotonic 64-bit clock on entry +// (see tick()), so nothing downstream has a rollover case. Unsigned-subtraction +// tricks are not enough here: they only survive a single wrap crossing, while an +// always-on observer accumulates uptime past 2^32 ms (~49.7 days), at which +// point a 32-bit tracker age would collapse back to a small value and the +// window would re-enter warm-up and divide 20 minutes of traffic by seconds. + +#define RADIO_ACTIVITY_BUCKETS 20 +#define RADIO_ACTIVITY_BUCKET_MS 60000UL + +// Beyond this, "time since last packet" stops being reported rather than shown +// as a stale or (after a 49-day rollover) nonsensical age. +#define RADIO_ACTIVITY_MAX_AGE_MS (100UL * RADIO_ACTIVITY_BUCKET_MS) + +struct RadioActivitySnapshot { + uint32_t packets; + uint32_t wire_bytes; + uint32_t airtime_ms; + int32_t snr_q4_sum; + int32_t rssi_sum; + + // Span the totals actually cover: 19 whole minutes plus the elapsed part of + // the current one, so it tops out just under 20 minutes and never claims + // coverage the ring does not have. + uint32_t window_ms; + uint32_t tracking_ms; // how long the tracker has been running + + uint32_t last_packet_age_ms; + bool has_last_packet; // false until the first packet, and once stale + + uint16_t peak_per_min; + uint16_t buckets[RADIO_ACTIVITY_BUCKETS]; // [0] oldest .. [N-1] current minute + + bool isEmpty() const { return packets == 0; } + + bool isWarmingUp() const { + return tracking_ms < (uint32_t)RADIO_ACTIVITY_BUCKETS * RADIO_ACTIVITY_BUCKET_MS; + } + uint32_t warmupMinutes() const { return tracking_ms / RADIO_ACTIVITY_BUCKET_MS; } + + // Derived values, in integer fixed point so host tests are exact and the + // formatting path stays off the FPU. All are zero when the window is empty. + uint32_t packetsPerMinuteX10() const { + if (window_ms == 0) return 0; + return (uint32_t)(((uint64_t)packets * RADIO_ACTIVITY_BUCKET_MS * 10) / window_ms); + } + uint32_t bytesPerSecondX10() const { + if (window_ms == 0) return 0; + return (uint32_t)(((uint64_t)wire_bytes * 1000 * 10) / window_ms); + } + uint32_t avgBytesPerPacket() const { + if (packets == 0) return 0; + return (wire_bytes + packets / 2) / packets; + } + // Receive airtime as tenths of a percent of the window. + uint32_t airtimePercentX10() const { + if (window_ms == 0) return 0; + return (uint32_t)(((uint64_t)airtime_ms * 1000) / window_ms); + } + // Average SNR in tenths of a dB (sums are quarter-dB units). + int32_t avgSnrX10() const { + if (packets == 0) return 0; + return (snr_q4_sum * 10) / ((int32_t)packets * 4); + } + int32_t avgRssi() const { + if (packets == 0) return 0; + return rssi_sum / (int32_t)packets; + } +}; + +class RadioActivityWindow { +public: + RadioActivityWindow() { reset(0); } + + void reset(uint32_t now_ms) { + memset(_buckets, 0, sizeof(_buckets)); + _head = 0; + _now_ms = 0; + _last_input_ms = now_ms; + _bucket_start_ms = 0; + _tracking_since_ms = 0; + _last_packet_ms = 0; + _ever_received = false; + } + + void recordPacket(uint32_t now_ms, uint16_t wire_bytes, uint32_t airtime_ms, int8_t snr_q4, + int16_t rssi_dbm) { + advance(now_ms); + + Bucket& b = _buckets[_head]; + // Saturated: drop the event whole so the bucket's averages stay consistent + // with its packet count. Unreachable at any real LoRa packet rate. + if (b.packets == 0xFFFF) return; + + b.packets++; + b.wire_bytes += wire_bytes; + b.airtime_ms += airtime_ms; + b.snr_q4_sum += snr_q4; + b.rssi_sum += rssi_dbm; + + _last_packet_ms = _now_ms; + _ever_received = true; + } + + void snapshot(uint32_t now_ms, RadioActivitySnapshot* out) { + advance(now_ms); + memset(out, 0, sizeof(*out)); + + for (int i = 0; i < RADIO_ACTIVITY_BUCKETS; i++) { + const Bucket& b = _buckets[(_head + 1 + i) % RADIO_ACTIVITY_BUCKETS]; + out->buckets[i] = b.packets; + out->packets += b.packets; + out->wire_bytes += b.wire_bytes; + out->airtime_ms += b.airtime_ms; + out->snr_q4_sum += b.snr_q4_sum; + out->rssi_sum += b.rssi_sum; + if (b.packets > out->peak_per_min) out->peak_per_min = b.packets; + } + + uint64_t elapsed_in_current = _now_ms - _bucket_start_ms; // < BUCKET_MS after advance() + uint64_t max_span = + (uint64_t)(RADIO_ACTIVITY_BUCKETS - 1) * RADIO_ACTIVITY_BUCKET_MS + elapsed_in_current; + uint64_t tracking = _now_ms - _tracking_since_ms; + const uint64_t full_span = (uint64_t)RADIO_ACTIVITY_BUCKETS * RADIO_ACTIVITY_BUCKET_MS; + + // Clamped, so the 32-bit snapshot fields stay in range on a long-lived node. + // Past full_span the exact tracker age is not needed: the window is warm. + out->tracking_ms = (uint32_t)(tracking < full_span ? tracking : full_span); + out->window_ms = (uint32_t)(tracking < max_span ? tracking : max_span); + + if (_ever_received) { + uint64_t age = _now_ms - _last_packet_ms; + if (age <= RADIO_ACTIVITY_MAX_AGE_MS) { + out->last_packet_age_ms = (uint32_t)age; + out->has_last_packet = true; + } + } + } + +private: + struct Bucket { + uint32_t wire_bytes; + uint32_t airtime_ms; + int32_t snr_q4_sum; + int32_t rssi_sum; + uint16_t packets; + uint16_t _reserved; + }; + + Bucket _buckets[RADIO_ACTIVITY_BUCKETS]; + uint64_t _now_ms; // monotonic clock, extended from the caller's + uint64_t _bucket_start_ms; // start of the current (newest) minute + uint64_t _tracking_since_ms; + uint64_t _last_packet_ms; + uint32_t _last_input_ms; // last 32-bit value the caller handed in + uint8_t _head; // ring index of the current minute + bool _ever_received; + + // Accumulates the delta since the previous call, which is correct across one + // millis() wrap. Read as signed so a caller handing back a slightly older + // reading counts as no time passing, rather than as a ~49-day leap forward + // that would expire the whole ring. Successive calls must therefore be less + // than 2^31 ms (~24.8 days) apart - guaranteed while the tracker is being + // serviced, and when it is not the ring is empty anyway. + void tick(uint32_t now_ms) { + int32_t delta = (int32_t)(now_ms - _last_input_ms); + if (delta <= 0) return; // stale or repeated reading: no time has passed + _now_ms += (uint32_t)delta; + _last_input_ms = now_ms; + } + + // Retires expired buckets lazily, advancing the boundary by whole BUCKET_MS + // steps so the minute phase is preserved across gaps. + void advance(uint32_t now_ms) { + tick(now_ms); + + uint64_t elapsed = _now_ms - _bucket_start_ms; + if (elapsed < RADIO_ACTIVITY_BUCKET_MS) return; + + uint64_t steps = elapsed / RADIO_ACTIVITY_BUCKET_MS; + _bucket_start_ms += steps * RADIO_ACTIVITY_BUCKET_MS; + + if (steps >= RADIO_ACTIVITY_BUCKETS) { + memset(_buckets, 0, sizeof(_buckets)); + _head = 0; + _tracking_since_ms = _bucket_start_ms; + return; + } + + for (uint64_t i = 0; i < steps; i++) { + _head = (uint8_t)((_head + 1) % RADIO_ACTIVITY_BUCKETS); + memset(&_buckets[_head], 0, sizeof(Bucket)); + } + } +}; + +static_assert(sizeof(RadioActivityWindow) <= 1024, "RadioActivityWindow must stay under 1 KiB"); diff --git a/src/helpers/ui/CHSC6XTouch.h b/src/helpers/ui/CHSC6XTouch.h new file mode 100644 index 00000000..c4c5cd2e --- /dev/null +++ b/src/helpers/ui/CHSC6XTouch.h @@ -0,0 +1,108 @@ +#pragma once + +#include +#include + +#include "TouchTapDetector.h" + +// Minimal polled driver for the CHSC6X capacitive touch controller on the +// Heltec V4 R8 Expansion Kit V2 panel. +// +// Only "is a finger down" is needed to toggle the display, so no coordinates +// and no calibration are read. TP_INT is not used: on this board it is an +// optional link (R13) on GPIO 43, which is also U0TXD - see HeltecV4R8Board.cpp +// for the verified pin map. + +#ifndef CHSC6X_I2C_ADDR +#define CHSC6X_I2C_ADDR 0x2E +#endif + +#define CHSC6X_READ_LEN 5 +#define CHSC6X_MAX_POINTS 1 + +class CHSC6XTouch { +public: + // Probes the bus. Returns false (and disables itself) when nothing answers, + // so a board without the touch panel simply carries on without it. + bool begin(TwoWire& wire = Wire) { + _wire = &wire; + _wire->beginTransmission((uint8_t)CHSC6X_I2C_ADDR); + _present = (_wire->endTransmission() == 0); + _detector.reset(millis()); + + #if defined(DISPLAY_TOUCH_DEBUG) && defined(PIN_TOUCH_INT) + pinMode(PIN_TOUCH_INT, INPUT_PULLUP); + #endif + + if (_present) { + Serial.printf("Touch: CHSC6X found at 0x%02X\n", CHSC6X_I2C_ADDR); + } else { + // Report what is actually on the bus, so an unexpected controller or + // address can be identified from a normal boot log. + Serial.printf("Touch: nothing at 0x%02X; I2C bus holds:", CHSC6X_I2C_ADDR); + for (uint8_t addr = 8; addr < 0x78; addr++) { + _wire->beginTransmission(addr); + if (_wire->endTransmission() == 0) Serial.printf(" 0x%02X", addr); + } + Serial.println(); + } + return _present; + } + + bool isPresent() const { return _present; } + + // True exactly once per new touch. + bool checkTap(uint32_t now_ms) { + if (!_present) return false; + return _detector.update(now_ms, readPressed()); + } + +private: + TwoWire* _wire = NULL; + bool _present = false; + TouchTapDetector _detector; + + bool readPressed() { + uint8_t got = _wire->requestFrom((uint8_t)CHSC6X_I2C_ADDR, (uint8_t)CHSC6X_READ_LEN); + if (got != CHSC6X_READ_LEN) { + while (_wire->available()) _wire->read(); // drain a short read + logRaw(got, NULL); + return false; + } + + uint8_t buf[CHSC6X_READ_LEN]; + for (uint8_t i = 0; i < CHSC6X_READ_LEN; i++) buf[i] = (uint8_t)_wire->read(); + logRaw(got, buf); + + // buf[0] is the reported touch-point count (buf[2]/buf[4] are x/y). It must + // be tested against a *valid* count, not merely against zero: an idle or + // NACKed read can come back as 0xFF, which "non-zero" reads as a finger + // held down forever - the tap detector then fires once and, seeing no + // release, never fires again. + return buf[0] >= 1 && buf[0] <= CHSC6X_MAX_POINTS; + } + +#ifdef DISPLAY_TOUCH_DEBUG + int16_t _logged = -1; + + // Logs on change only, so a normal boot stays quiet. + void logRaw(uint8_t got, const uint8_t* buf) { + int16_t key = buf ? (int16_t)buf[0] : (int16_t)(-2 - (int16_t)got); + if (key == _logged) return; + _logged = key; + + if (!buf) { + Serial.printf("Touch: short read (%u of %u bytes)\n", got, CHSC6X_READ_LEN); + return; + } + Serial.printf("Touch: raw %02X %02X %02X %02X %02X", buf[0], buf[1], buf[2], buf[3], buf[4]); + #ifdef PIN_TOUCH_INT + // Pulled up, so an unfitted R13 sits steady HIGH and a wired INT pulses LOW. + Serial.printf(" INT=%d", digitalRead(PIN_TOUCH_INT)); + #endif + Serial.println(); + } +#else + void logRaw(uint8_t, const uint8_t*) {} +#endif +}; diff --git a/src/helpers/ui/ObserverDashboard.h b/src/helpers/ui/ObserverDashboard.h new file mode 100644 index 00000000..c8bacd84 --- /dev/null +++ b/src/helpers/ui/ObserverDashboard.h @@ -0,0 +1,524 @@ +#pragma once + +#include +#include +#include + +#include "../RadioActivityWindow.h" +#include "DisplayDriver.h" +#include "DisplayFrameSignature.h" + +// Observer analytics dashboard for the Heltec V4 R8 TFT targets. +// +// Fork-owned and self-contained: the role UITasks only pick a layout, hand over +// a Context plus a snapshot, and ask for a full frame or a single changed row. +// Everything here is host-buildable against DisplayDriver, so the layout, the +// formatting and the redraw policy are all covered by test_observer_dashboard. + +namespace ObserverDashboard { + +// ---------------------------------------------------------------- palette --- +// Kept local to the dashboard. applyDarkPalette() retunes the shared UIColor +// slots at runtime, which is why no display driver needs editing for the theme. + +constexpr ColorVal rgb565(uint8_t r, uint8_t g, uint8_t b) { + return (ColorVal)(((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)); +} + +constexpr ColorVal BG = rgb565(10, 12, 16); +constexpr ColorVal HEADER_BG = rgb565(18, 38, 74); +constexpr ColorVal HEADER_SUB = rgb565(130, 170, 214); +constexpr ColorVal TEXT = rgb565(232, 238, 246); +constexpr ColorVal MUTED = rgb565(122, 134, 150); +constexpr ColorVal ACCENT = rgb565(64, 176, 240); +constexpr ColorVal BAR = rgb565(38, 116, 168); +constexpr ColorVal BAR_NOW = rgb565(96, 208, 255); +constexpr ColorVal GRID = rgb565(38, 46, 60); +constexpr ColorVal GOOD = rgb565(72, 208, 136); +constexpr ColorVal WARN = rgb565(248, 176, 72); + +// Retunes the shared colour slots so every screen this UITask draws - boot, +// setup portal, reboot, power-off and the dashboard - is coherently dark. +inline void applyDarkPalette() { + UIColor::window_bkg = BG; + UIColor::title_bkg = HEADER_BG; + UIColor::title_txt = TEXT; + UIColor::primary_txt = TEXT; + UIColor::secondary_txt = MUTED; + UIColor::warning_txt = WARN; + UIColor::popup_bkg = HEADER_BG; + UIColor::popup_txt = TEXT; + UIColor::corp_blue = ACCENT; +} + +// ----------------------------------------------------------------- layout --- +// Logical 128x64 coordinates, as the shared DisplayDriver API expects. The two +// orientations need separate row pitches: a text row is a fixed 16 physical +// pixels, which is 3.2 logical units in portrait (y scale 5) but 4.27 in +// landscape (y scale 3.75), so one shared grid would overlap in landscape. + +struct Layout { + int16_t margin_x; + int16_t right_x; // right edge of the content column (exclusive) + int16_t header_h; + int16_t header_text_y; + int16_t header_sub_y; // second header row for the role label; -1 = same row + int16_t radio_y; + int16_t window_y; + int16_t headline_y; + int16_t headline_h; + int16_t rate_y; + int16_t graph_y; + int16_t graph_h; // includes the one-unit baseline at the bottom + int16_t rf_y; + int16_t status_y; + int16_t text_h; // logical height of one size-1 text row + int16_t max_chars; // size-1 characters that fit between the margins + int16_t max_chars_big; // size-2 characters that fit +}; + +// 240x320 panel: x scale 1.875, y scale 5, 12x16 glyphs (24x32 at size 2). +constexpr Layout portraitLayout() { + return Layout{ + /*margin_x*/ 4, /*right_x*/ 124, + /*header_h*/ 9, /*header_text_y*/ 1, /*header_sub_y*/ 5, + /*radio_y*/ 11, + /*window_y*/ 16, + /*headline_y*/ 21, /*headline_h*/ 7, + /*rate_y*/ 29, + /*graph_y*/ 34, /*graph_h*/ 16, + /*rf_y*/ 52, + /*status_y*/ 57, + /*text_h*/ 4, + /*max_chars*/ 18, /*max_chars_big*/ 9}; +} + +// 320x240 panel: x scale 2.5, y scale 3.75, 12x16 glyphs (30x40 at size 2). +constexpr Layout landscapeLayout() { + return Layout{ + /*margin_x*/ 4, /*right_x*/ 124, + /*header_h*/ 7, /*header_text_y*/ 1, /*header_sub_y*/ -1, + /*radio_y*/ 9, + /*window_y*/ 14, + /*headline_y*/ 19, /*headline_h*/ 12, + /*rate_y*/ 31, + /*graph_y*/ 36, /*graph_h*/ 12, + /*rf_y*/ 50, + /*status_y*/ 56, + /*text_h*/ 5, + /*max_chars*/ 25, /*max_chars_big*/ 10}; +} + +inline const Layout& activeLayout() { +#ifdef ST7789_PORTRAIT_PROFILE + static const Layout layout = portraitLayout(); +#else + static const Layout layout = landscapeLayout(); +#endif + return layout; +} + +// ------------------------------------------------------------- formatting --- +// Every value is formatted from integers. Arguments are widened explicitly so +// the same code is correct on a 32-bit target and on a 64-bit host. + +inline void formatCompactCount(char* out, size_t n, uint32_t v) { + if (v < 10000) { + snprintf(out, n, "%lu", (unsigned long)v); + } else if (v < 100000) { + snprintf(out, n, "%lu.%luk", (unsigned long)(v / 1000), (unsigned long)((v % 1000) / 100)); + } else if (v < 1000000) { + snprintf(out, n, "%luk", (unsigned long)(v / 1000)); + } else if (v < 100000000) { + snprintf(out, n, "%lu.%luM", (unsigned long)(v / 1000000), + (unsigned long)((v % 1000000) / 100000)); + } else { + snprintf(out, n, "%luM", (unsigned long)(v / 1000000)); + } +} + +inline void formatCompactBytes(char* out, size_t n, uint32_t v) { + if (v < 1024) { + snprintf(out, n, "%lu B", (unsigned long)v); + return; + } + if (v < 1048576UL) { + uint32_t tenths = (uint32_t)(((uint64_t)v * 10) / 1024); + if (tenths < 1000) { + snprintf(out, n, "%lu.%lu KB", (unsigned long)(tenths / 10), (unsigned long)(tenths % 10)); + } else { + snprintf(out, n, "%lu KB", (unsigned long)(tenths / 10)); + } + return; + } + uint32_t tenths = (uint32_t)(((uint64_t)v * 10) / 1048576UL); + if (tenths < 1000) { + snprintf(out, n, "%lu.%lu MB", (unsigned long)(tenths / 10), (unsigned long)(tenths % 10)); + } else { + snprintf(out, n, "%lu MB", (unsigned long)(tenths / 10)); + } +} + +// One decimal below 100, whole numbers above, so the field cannot grow wide. +inline void formatTenths(char* out, size_t n, uint32_t tenths) { + if (tenths < 1000) { + snprintf(out, n, "%lu.%lu", (unsigned long)(tenths / 10), (unsigned long)(tenths % 10)); + } else { + formatCompactCount(out, n, tenths / 10); + } +} + +inline void formatSignedTenths(char* out, size_t n, int32_t tenths) { + const char* sign = tenths < 0 ? "-" : "+"; + uint32_t mag = (uint32_t)(tenths < 0 ? -tenths : tenths); + snprintf(out, n, "%s%lu.%lu", sign, (unsigned long)(mag / 10), (unsigned long)(mag % 10)); +} + +// Quantised to the 5 s activity cadence so the string is stable within a tick +// and cannot make the status row repaint more often than the panel updates. +inline uint32_t quantizeAgeSecs(uint32_t age_ms) { return (age_ms / 5000) * 5; } + +inline void formatAge(char* out, size_t n, uint32_t age_ms, bool valid) { + if (!valid) { + snprintf(out, n, "--"); + return; + } + uint32_t secs = quantizeAgeSecs(age_ms); + if (secs < 5) { + snprintf(out, n, "now"); + } else if (secs < 60) { + snprintf(out, n, "%lus", (unsigned long)secs); + } else if (secs < 3600) { + snprintf(out, n, "%lum", (unsigned long)(secs / 60)); + } else if (secs < 86400) { + snprintf(out, n, "%luh", (unsigned long)(secs / 3600)); + } else { + snprintf(out, n, "%lud", (unsigned long)(secs / 86400)); + } +} + +// "910.525 SF7 BW62.5" / "869.618 SF8 BW250" - a trailing ".0" on the +// bandwidth would push the widest case past the content column. +inline void formatRadioStrip(char* out, size_t n, float freq, uint8_t sf, float bw) { + int32_t tenths = (int32_t)(bw * 10.0f + 0.5f); + char bw_str[12]; + if (tenths % 10 == 0) { + snprintf(bw_str, sizeof(bw_str), "%ld", (long)(tenths / 10)); + } else { + snprintf(bw_str, sizeof(bw_str), "%ld.%ld", (long)(tenths / 10), (long)(tenths % 10)); + } + snprintf(out, n, "%.3f SF%u BW%s", (double)freq, (unsigned)sf, bw_str); +} + +// Trims to a character budget rather than a measured width. The font is fixed +// width, so the budget is exact - and DisplayDriver::drawTextEllipsized() must +// not be used here: it trims against getTextWidth(), which reports an +// over-long string at the portrait driver's *fallback* scale and so stops +// trimming while the string is still too wide to draw at full size. +inline void fitToChars(char* out, size_t n, const char* src, int max_chars) { + if (max_chars < 0) max_chars = 0; + if ((size_t)max_chars > n - 1) max_chars = (int)(n - 1); + + size_t len = src ? strlen(src) : 0; + if (len <= (size_t)max_chars) { + memcpy(out, src ? src : "", len); + out[len] = 0; + return; + } + if (max_chars <= 3) { + memcpy(out, src, (size_t)max_chars); + out[max_chars] = 0; + return; + } + memcpy(out, src, (size_t)max_chars - 3); + memcpy(out + max_chars - 3, "...", 4); +} + +// ------------------------------------------------------------- row content -- + +enum Row { ROW_WINDOW = 0, ROW_HEADLINE, ROW_RATE, ROW_GRAPH, ROW_RF, ROW_STATUS, ROW_COUNT }; + +struct Context { + const char* node_name; + const char* role_label; // "REPEATER" / "ROOM SERVER" + float freq; + uint8_t sf; + float bw; + bool link_up; +}; + +struct RowText { + char left[24]; + char right[24]; + ColorVal left_color; + ColorVal right_color; +}; + +inline void composeRow(Row row, const Context& ctx, const RadioActivitySnapshot& s, RowText* out) { + out->left[0] = out->right[0] = 0; + out->left_color = TEXT; + out->right_color = MUTED; + + char scratch[24]; + switch (row) { + case ROW_WINDOW: + if (s.isWarmingUp()) { + snprintf(out->left, sizeof(out->left), "LIVE %lum", (unsigned long)s.warmupMinutes()); + } else { + snprintf(out->left, sizeof(out->left), "LAST 20m"); + } + out->left_color = MUTED; + if (s.peak_per_min > 0) { + formatCompactCount(scratch, sizeof(scratch), s.peak_per_min); + snprintf(out->right, sizeof(out->right), "max %s/m", scratch); + } + break; + + case ROW_HEADLINE: + if (s.isEmpty()) { + snprintf(out->left, sizeof(out->left), "No RF yet"); + out->left_color = MUTED; + } else { + formatCompactCount(scratch, sizeof(scratch), s.packets); + snprintf(out->left, sizeof(out->left), "%s pkt", scratch); + out->left_color = BAR_NOW; + } + break; + + case ROW_RATE: + formatCompactBytes(out->left, sizeof(out->left), s.wire_bytes); + formatTenths(scratch, sizeof(scratch), s.packetsPerMinuteX10()); + snprintf(out->right, sizeof(out->right), "%s/min", scratch); + if (s.isEmpty()) { + out->left_color = MUTED; + } + break; + + case ROW_RF: + if (s.isEmpty()) { + snprintf(out->left, sizeof(out->left), "SNR --"); + out->left_color = MUTED; + } else { + int32_t snr = s.avgSnrX10(); + formatSignedTenths(scratch, sizeof(scratch), snr); + snprintf(out->left, sizeof(out->left), "SNR %s", scratch); + out->left_color = snr >= 0 ? GOOD : (snr >= -70 ? TEXT : WARN); + } + { + uint32_t air = s.airtimePercentX10(); + formatTenths(scratch, sizeof(scratch), air); + snprintf(out->right, sizeof(out->right), "AIR %s%%", scratch); + out->right_color = air >= 100 ? WARN : MUTED; + } + break; + + case ROW_STATUS: + formatAge(scratch, sizeof(scratch), s.last_packet_age_ms, s.has_last_packet); + snprintf(out->left, sizeof(out->left), "RX %s", scratch); + out->left_color = s.has_last_packet ? TEXT : MUTED; + snprintf(out->right, sizeof(out->right), ctx.link_up ? "WiFi OK" : "WiFi --"); + out->right_color = ctx.link_up ? GOOD : WARN; + break; + + default: + break; + } +} + +inline int16_t rowY(const Layout& l, Row row) { + switch (row) { + case ROW_WINDOW: return l.window_y; + case ROW_HEADLINE: return l.headline_y; + case ROW_RATE: return l.rate_y; + case ROW_GRAPH: return l.graph_y; + case ROW_RF: return l.rf_y; + default: return l.status_y; + } +} + +inline int16_t rowH(const Layout& l, Row row) { + if (row == ROW_HEADLINE) return l.headline_h; + if (row == ROW_GRAPH) return l.graph_h; + return l.text_h; +} + +// --------------------------------------------------------------- the graph -- + +// Bar heights in logical units, oldest first. Any minute with traffic rounds up +// to at least one unit; empty minutes stay empty. +inline void barHeights(const Layout& l, const RadioActivitySnapshot& s, + uint8_t out[RADIO_ACTIVITY_BUCKETS]) { + uint16_t scale = s.peak_per_min > 0 ? s.peak_per_min : 1; + int16_t max_h = l.graph_h - 1; // the last unit is the baseline + for (int i = 0; i < RADIO_ACTIVITY_BUCKETS; i++) { + uint32_t v = s.buckets[i]; + out[i] = v == 0 ? 0 : (uint8_t)((v * max_h + scale - 1) / scale); + } +} + +inline int16_t barSlot(const Layout& l) { + return (int16_t)((l.right_x - l.margin_x) / RADIO_ACTIVITY_BUCKETS); +} + +// ------------------------------------------------------------- signatures --- +// One signature per row, computed from exactly what is drawn, so a repaint +// happens only where the pixels actually differ. + +inline uint32_t rowSignature(const Layout& l, Row row, const Context& ctx, + const RadioActivitySnapshot& s) { + uint32_t sig = DisplayFrameSignature::INITIAL; + if (row == ROW_GRAPH) { + uint8_t h[RADIO_ACTIVITY_BUCKETS]; + barHeights(l, s, h); + char buf[4 * RADIO_ACTIVITY_BUCKETS]; + size_t p = 0; + for (int i = 0; i < RADIO_ACTIVITY_BUCKETS && p + 4 < sizeof(buf); i++) { + p += (size_t)snprintf(buf + p, sizeof(buf) - p, "%u,", (unsigned)h[i]); + } + return DisplayFrameSignature::append(sig, buf); + } + + RowText t; + composeRow(row, ctx, s, &t); + sig = DisplayFrameSignature::append(sig, t.left); + return DisplayFrameSignature::append(sig, t.right); +} + +inline void allRowSignatures(const Layout& l, const Context& ctx, const RadioActivitySnapshot& s, + uint32_t out[ROW_COUNT]) { + for (int r = 0; r < ROW_COUNT; r++) out[r] = rowSignature(l, (Row)r, ctx, s); +} + +// ------------------------------------------------------------------ render -- + +// Right-hand text is measured and placed first, then the left text is +// ellipsized into whatever column is left, so the two can never collide. +// +// The right edge gets a one-unit gutter: getTextWidth() converts physical +// glyph widths back to logical units and rounds, so anchoring flush at right_x +// can land a pixel past it. +inline void drawPair(DisplayDriver& d, const Layout& l, int16_t y, const RowText& t, + int max_chars) { + int right_len = (int)strlen(t.right); + if (right_len > 0) { + int16_t rw = (int16_t)d.getTextWidth(t.right); + d.setColor(t.right_color); + d.setCursor((int16_t)(l.right_x - rw - 1), y); + d.print(t.right); + } + + int budget = max_chars - (right_len > 0 ? right_len + 1 : 0); + if (t.left[0] && budget > 0) { + char fitted[32]; + fitToChars(fitted, sizeof(fitted), t.left, budget); + d.setColor(t.left_color); + d.setCursor(l.margin_x, y); + d.print(fitted); + } +} + +inline void clearRow(DisplayDriver& d, const Layout& l, Row row) { + d.setColor(BG); + d.fillRect(l.margin_x, rowY(l, row), l.right_x - l.margin_x, rowH(l, row)); +} + +inline void drawGraph(DisplayDriver& d, const Layout& l, const RadioActivitySnapshot& s) { + int16_t base_y = (int16_t)(l.graph_y + l.graph_h - 1); + d.setColor(GRID); + d.fillRect(l.margin_x, base_y, l.right_x - l.margin_x, 1); + + uint8_t h[RADIO_ACTIVITY_BUCKETS]; + barHeights(l, s, h); + int16_t slot = barSlot(l); + for (int i = 0; i < RADIO_ACTIVITY_BUCKETS; i++) { + if (h[i] == 0) continue; + d.setColor(i == RADIO_ACTIVITY_BUCKETS - 1 ? BAR_NOW : BAR); + d.fillRect((int16_t)(l.margin_x + i * slot), (int16_t)(base_y - h[i]), (int16_t)(slot - 1), + h[i]); + } +} + +inline void drawRow(DisplayDriver& d, const Layout& l, Row row, const Context& ctx, + const RadioActivitySnapshot& s, bool clear_first) { + if (clear_first) clearRow(d, l, row); + + if (row == ROW_GRAPH) { + drawGraph(d, l, s); + return; + } + + RowText t; + composeRow(row, ctx, s, &t); + d.setTextSize(row == ROW_HEADLINE ? 2 : 1); + drawPair(d, l, rowY(l, row), t, row == ROW_HEADLINE ? l.max_chars_big : l.max_chars); + if (row == ROW_HEADLINE) d.setTextSize(1); +} + +inline void drawHeader(DisplayDriver& d, const Layout& l, const Context& ctx) { + d.setColor(HEADER_BG); + d.fillRect(0, 0, 128, l.header_h); + d.setTextSize(1); + + RowText t{}; + t.left_color = TEXT; + t.right_color = HEADER_SUB; + snprintf(t.left, sizeof(t.left), "%s", ctx.node_name ? ctx.node_name : ""); + snprintf(t.right, sizeof(t.right), "%s", ctx.role_label ? ctx.role_label : ""); + + if (l.header_sub_y < 0) { + drawPair(d, l, l.header_text_y, t, l.max_chars); // both fit on one line + return; + } + + // Narrow panel: the node name keeps the whole width and the role drops to a + // second line, rather than the name being ellipsized down to a few letters. + RowText name{}; + name.left_color = t.left_color; + memcpy(name.left, t.left, sizeof(name.left)); + drawPair(d, l, l.header_text_y, name, l.max_chars); + + RowText role{}; + role.left_color = t.right_color; + memcpy(role.left, t.right, sizeof(role.left)); + drawPair(d, l, l.header_sub_y, role, l.max_chars); +} + +inline void drawRadioStrip(DisplayDriver& d, const Layout& l, const Context& ctx) { + char tmp[32]; + formatRadioStrip(tmp, sizeof(tmp), ctx.freq, ctx.sf, ctx.bw); + char fitted[32]; + fitToChars(fitted, sizeof(fitted), tmp, l.max_chars); + d.setTextSize(1); + d.setColor(MUTED); + d.setCursor(l.margin_x, l.radio_y); + d.print(fitted); +} + +// Complete repaint. The caller has already run startFrame(), which clears to +// UIColor::window_bkg - the dark background applyDarkPalette() installed. +inline void drawFull(DisplayDriver& d, const Layout& l, const Context& ctx, + const RadioActivitySnapshot& s) { + drawHeader(d, l, ctx); + drawRadioStrip(d, l, ctx); + for (int r = 0; r < ROW_COUNT; r++) drawRow(d, l, (Row)r, ctx, s, false); +} + +// Repaints only the rows whose signature moved. Never touches the header, the +// radio strip, or anything outside the analytics rows, so no startFrame() and +// no whole-screen clear is involved. +inline bool drawChangedRows(DisplayDriver& d, const Layout& l, const Context& ctx, + const RadioActivitySnapshot& s, uint32_t signatures[ROW_COUNT]) { + uint32_t fresh[ROW_COUNT]; + allRowSignatures(l, ctx, s, fresh); + + bool drew = false; + for (int r = 0; r < ROW_COUNT; r++) { + if (fresh[r] == signatures[r]) continue; + drawRow(d, l, (Row)r, ctx, s, true); + signatures[r] = fresh[r]; + drew = true; + } + return drew; +} + +} // namespace ObserverDashboard diff --git a/src/helpers/ui/ST7789LCDDisplay.cpp b/src/helpers/ui/ST7789LCDDisplay.cpp index 1d00fd15..bcdaf81c 100644 --- a/src/helpers/ui/ST7789LCDDisplay.cpp +++ b/src/helpers/ui/ST7789LCDDisplay.cpp @@ -112,13 +112,18 @@ void ST7789LCDDisplay::turnOff() { digitalWrite(PIN_TFT_LEDA_CTL, HIGH); #endif } + #ifndef HELTEC_V4_R8_TFT if (PIN_TFT_RST != -1) { digitalWrite(PIN_TFT_RST, LOW); } - #ifndef HELTEC_V4_R8_TFT if (PIN_TFT_LEDA_CTL != -1) { digitalWrite(PIN_TFT_LEDA_CTL, LOW); } + #else + // On the V4 R8 Expansion Kit this reset line is shared with the touch + // panel's TP_RST, so parking it low would hold the touch controller in + // reset for as long as the display is off. Killing the backlight is what + // "off" means for this LCD anyway. #endif _isOn = false; diff --git a/src/helpers/ui/TouchTapDetector.h b/src/helpers/ui/TouchTapDetector.h new file mode 100644 index 00000000..b2a610f4 --- /dev/null +++ b/src/helpers/ui/TouchTapDetector.h @@ -0,0 +1,61 @@ +#pragma once + +#include + +// Debounced rising-edge detector for a polled touch panel. +// +// Pure logic: no Arduino, no I2C. The caller polls the panel and hands over a +// raw "finger down" reading; this decides when that counts as a new tap. All +// elapsed-time comparisons are unsigned subtractions, so millis() rollover is +// a non-event. + +#ifndef TOUCH_TAP_DEBOUNCE_MS +#define TOUCH_TAP_DEBOUNCE_MS 40 +#endif + +// Ignores a second tap arriving this soon after an accepted one, so a bouncy +// panel or a slightly long press cannot toggle the display twice. +#ifndef TOUCH_TAP_MIN_GAP_MS +#define TOUCH_TAP_MIN_GAP_MS 400 +#endif + +class TouchTapDetector { +public: + TouchTapDetector() { reset(0); } + + void reset(uint32_t now_ms = 0) { + _raw = false; + _stable = false; + _changed_at = now_ms; + _last_tap = now_ms; + _tapped_before = false; + } + + // Returns true exactly once per accepted finger-down. + bool update(uint32_t now_ms, bool pressed) { + if (pressed != _raw) { // reading moved; restart the settling window + _raw = pressed; + _changed_at = now_ms; + return false; + } + if (now_ms - _changed_at < TOUCH_TAP_DEBOUNCE_MS) return false; // not settled + if (_raw == _stable) return false; // nothing new + + _stable = _raw; + if (!_stable) return false; // this is the release, not a tap + + if (_tapped_before && (now_ms - _last_tap) < TOUCH_TAP_MIN_GAP_MS) return false; + _last_tap = now_ms; + _tapped_before = true; + return true; + } + + bool isTouched() const { return _stable; } + +private: + uint32_t _changed_at; + uint32_t _last_tap; + bool _raw; + bool _stable; + bool _tapped_before; +}; diff --git a/test/README.md b/test/README.md index 901227ef..647b6daf 100644 --- a/test/README.md +++ b/test/README.md @@ -38,6 +38,9 @@ does not reflect the GoogleTest count — run the built binary directly | `test_mqtt_prefs_serializer` | `src/helpers/MQTTPrefsSerializer.h`, `src/helpers/ConfigSerializer.*` | semantic nested `/mqtt.json` round trips; numeric slot keys; required/future version handling; strict length/overflow/duplicate rejection; safe semantic repair; scratch-before-live loading | | `test_mqtt_prefs_atomic_store` | `src/helpers/MQTTPrefsAtomicStore.h`, `src/helpers/MQTTPrefsRecovery.h` | production JSON begin/write/checksum-finish/schema-verify/commit orchestration; first-migration and rename-boundary recovery; legacy `/node_prefs` handoff; failure cleanup and original-file preservation | | `test_mqtt_payload_builder` | `src/helpers/MQTTPayloadBuilder.cpp` | status/packet/raw JSON contracts; optional fields; escaping; RX metrics and path; score handling; exact buffer bounds; maximum representative payloads | +| `test_radio_activity_window` | `src/helpers/RadioActivityWindow.h` | 20-minute minute-bucketed RX window: totals and derived rates; bucket rotation and oldest-to-newest ordering; expiry at the boundary; ring clear after 20 minutes of silence; warm-up versus steady-state denominators; peak minute; last-packet age and staleness; counter saturation; `millis()` rollover, including the minute boundary a `now_ms / 60000` quotient would corrupt | +| `test_observer_dashboard` | `src/helpers/ui/ObserverDashboard.h` | R8 TFT observer dashboard against a recording `DisplayDriver` in both orientation profiles: compact number/byte/age formatting and the 5 s age quantisation; per-row character budgets; on-panel and inside-the-margin bounds; no silent portrait scale fallback; non-overlapping row rectangles and each row's repaint covering everything it draws; 20-bar graph scaling, ordering and empty/spike cases; per-row signatures and the partial-repaint policy | +| `test_touch_tap_detector` | `src/helpers/ui/TouchTapDetector.h` | debounced rising-edge detection for the polled Expansion Kit touch panel: idle quiet; one tap per touch; long presses do not repeat; sub-debounce blips ignored; contact bounce still counts once; minimum gap between accepted taps; `millis()` rollover; reset semantics | | `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) | ## Conventions (and how to add a suite) diff --git a/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp b/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp index ce91b0c1..8c8bf32c 100644 --- a/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp +++ b/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp @@ -78,6 +78,7 @@ static MQTTPrefs defaults() { prefs.alert_mqtt_minutes = 240; prefs.alert_min_interval_min = 60; prefs.mqtt_neighbors_interval = MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS; + prefs.display_timeout_secs = DISPLAY_TIMEOUT_DEFAULT_SECS; strcpy(prefs.snmp_community, "public"); for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) { strcpy(prefs.mqtt_slot_preset[i], "none"); @@ -375,6 +376,72 @@ TEST(MQTTPrefsSerializer, SaveNormalizationIsIdempotentAgainstKnownDefaults) { EXPECT_FALSE(repaired) << output.text(); } +TEST(MQTTPrefsSerializer, DisplayTimeoutRoundTrips) { + for (uint16_t secs : {(uint16_t)0, (uint16_t)45, DISPLAY_TIMEOUT_MAX_SECS}) { + MQTTPrefs source = defaults(); + source.display_timeout_secs = secs; + + OutputStream output; + MQTTPrefsSerializer writer(&source); + ASSERT_TRUE(writer.saveSerial(output)) << secs; + + MQTTPrefs loaded = defaults(); + InputStream input(output.text()); + MQTTPrefsSerializer reader(&loaded); + ASSERT_TRUE(reader.loadSerial(input)) << secs; + bool repaired = false; + ASSERT_TRUE(reader.apply(&repaired)) << secs; + EXPECT_FALSE(repaired) << secs; + EXPECT_EQ(secs, loaded.display_timeout_secs); + } +} + +TEST(MQTTPrefsSerializer, RepairsDisplayTimeoutOutOfRange) { + MQTTPrefs prefs = defaults(); + InputStream input("{version:1,display:{timeout_s:99999}}"); + MQTTPrefsSerializer serializer(&prefs); + ASSERT_TRUE(serializer.loadSerial(input)); + bool repaired = false; + ASSERT_TRUE(serializer.apply(&repaired)); + EXPECT_TRUE(repaired); + EXPECT_EQ(DISPLAY_TIMEOUT_DEFAULT_SECS, prefs.display_timeout_secs); + + prefs = defaults(); + InputStream negative("{version:1,display:{timeout_s:-5}}"); + MQTTPrefsSerializer negative_serializer(&prefs); + ASSERT_TRUE(negative_serializer.loadSerial(negative)); + repaired = false; + ASSERT_TRUE(negative_serializer.apply(&repaired)); + EXPECT_TRUE(repaired); + EXPECT_EQ(DISPLAY_TIMEOUT_DEFAULT_SECS, prefs.display_timeout_secs); +} + +TEST(MQTTPrefsSerializer, PrefsWrittenBeforeTheDisplayGroupStillLoad) { + // Upgrade path: a /mqtt.json from firmware without the display group must + // load cleanly and keep the default rather than collapsing to 0 ("stay on"). + MQTTPrefs prefs = defaults(); + InputStream input("{version:1,radio:{watchdog_min:5}}"); + MQTTPrefsSerializer serializer(&prefs); + ASSERT_TRUE(serializer.loadSerial(input)); + bool repaired = false; + ASSERT_TRUE(serializer.apply(&repaired)); + EXPECT_EQ(DISPLAY_TIMEOUT_DEFAULT_SECS, prefs.display_timeout_secs); +} + +TEST(MQTTPrefsSerializer, UnknownGroupsAreIgnoredSoAppendedKeysAreDowngradeSafe) { + // The mirror of the case above, and the reason appending `display` needed no + // MQTT_PREFS_JSON_FORMAT_VERSION bump: firmware that predates a group skips + // it rather than failing the load. + MQTTPrefs prefs = defaults(); + InputStream input( + "{version:1,display:{timeout_s:45},future:{thing:1,nested:{x:2}}}"); + MQTTPrefsSerializer serializer(&prefs); + ASSERT_TRUE(serializer.loadSerial(input)); + bool repaired = false; + ASSERT_TRUE(serializer.apply(&repaired)); + EXPECT_EQ(45, prefs.display_timeout_secs); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/test/test_observer_dashboard/MockDisplay.h b/test/test_observer_dashboard/MockDisplay.h new file mode 100644 index 00000000..23d243fe --- /dev/null +++ b/test/test_observer_dashboard/MockDisplay.h @@ -0,0 +1,122 @@ +#pragma once + +#include "helpers/ui/DisplayDriver.h" +#include "helpers/ui/DisplayViewport.h" + +#include +#include + +// A DisplayDriver that records physical-pixel draw calls instead of pushing +// them at a panel, reproducing the two real ST7789LCDDisplay coordinate and +// text-metric profiles: +// +// portrait (ST7789_PORTRAIT_PROFILE) 240x320, DisplayViewport mapping, +// physical text scale = logical * 2 +// landscape (default) 320x240, x * 2.5 / y * 3.75, +// physical text scale = (int)(logical * 2.5) +// +// Text extents are recorded at the size the layout *asked* for, so a row that +// only fits because the driver would silently shrink or clip it still shows up +// as an out-of-bounds op. +class MockDisplay : public DisplayDriver { +public: + enum Mode { PORTRAIT, LANDSCAPE }; + + struct Op { + enum Kind { FILL, RECT, TEXT } kind; + int x, y, w, h; // physical pixels + ColorVal color; + std::string text; + int logical_size; + bool scale_fallback; // portrait driver would have shrunk this string + }; + + explicit MockDisplay(Mode mode) + : DisplayDriver(128, 64), _mode(mode), _on(true), _color(0), _size(1), _cx(0), _cy(0) {} + + std::vector ops; + + int panelWidth() const { return _mode == PORTRAIT ? 240 : 320; } + int panelHeight() const { return _mode == PORTRAIT ? 320 : 240; } + + void reset() { ops.clear(); } + + // --- DisplayDriver --- + bool isOn() override { return _on; } + void turnOn() override { _on = true; } + void turnOff() override { _on = false; } + void clear() override { ops.clear(); } + void startFrame(ColorVal bkg = UIColor::window_bkg) override { + ops.clear(); + ops.push_back(Op{Op::FILL, 0, 0, panelWidth(), panelHeight(), bkg, "", 1, false}); + _size = 1; + } + void setTextSize(int sz) override { _size = sz > 0 ? sz : 1; } + void setColor(ColorVal c) override { _color = c; } + void setCursor(int x, int y) override { _cx = x; _cy = y; } + + void print(const char* str) override { + if (!str || !*str) return; + int n = (int)strlen(str); + int scale = physicalScale(_size); + int px = mapX(_cx), py = mapY(_cy); + bool fallback = false; + if (_mode == PORTRAIT) { + int available = panelWidth() - px; + if (n * 6 * scale > available) { + fallback = true; // the real driver drops to the minimum scale here + } + } + ops.push_back(Op{Op::TEXT, px, py, n * 6 * scale, 8 * scale, _color, std::string(str), _size, + fallback}); + _cx += (int)((n * 6 * scale) / xScale()); + } + + void fillRect(int x, int y, int w, int h) override { + ops.push_back(Op{Op::FILL, mapX(x), mapY(y), spanX(x, w), spanY(y, h), _color, "", _size, + false}); + } + void drawRect(int x, int y, int w, int h) override { + ops.push_back(Op{Op::RECT, mapX(x), mapY(y), spanX(x, w), spanY(y, h), _color, "", _size, + false}); + } + void drawXbm(int, int, const uint8_t*, int, int) override {} + void endFrame() override {} + + uint16_t getTextWidth(const char* str) override { + if (!str) return 0; + int n = (int)strlen(str); + int scale = physicalScale(_size); + if (_mode == PORTRAIT) { + // Mirrors ST7789LCDDisplay::getTextWidth(): measure at the scale the + // driver would pick, clamp to the panel, convert back to logical. + if (n * 6 * scale > panelWidth()) scale = _size; + int w = n * 6 * scale; + if (w > panelWidth()) w = panelWidth(); + DisplayViewport::Geometry g{128, 64, 240, 320}; + return g.logicalWidthForPhysical((uint16_t)w); + } + return (uint16_t)((n * 6 * scale) / 2.5f); + } + +private: + Mode _mode; + bool _on; + ColorVal _color; + int _size, _cx, _cy; + + float xScale() const { return _mode == PORTRAIT ? (240.0f / 128.0f) : 2.5f; } + + int physicalScale(int logical) const { + return _mode == PORTRAIT ? logical * 2 : (int)(uint8_t)(logical * 2.5f); + } + + int mapX(int x) const { + return _mode == PORTRAIT ? (int)((int32_t)x * 240 / 128) : (int)(x * 2.5f); + } + int mapY(int y) const { + return _mode == PORTRAIT ? (int)((int32_t)y * 320 / 64) : (int)(y * 3.75f); + } + int spanX(int x, int w) const { return mapX(x + w) - mapX(x); } + int spanY(int y, int h) const { return mapY(y + h) - mapY(y); } +}; diff --git a/test/test_observer_dashboard/test_observer_dashboard.cpp b/test/test_observer_dashboard/test_observer_dashboard.cpp new file mode 100644 index 00000000..7e036bba --- /dev/null +++ b/test/test_observer_dashboard/test_observer_dashboard.cpp @@ -0,0 +1,581 @@ +#include "MockDisplay.h" +#include "helpers/ui/ObserverDashboard.h" + +#include + +// UIColor's slots live in whichever display driver a firmware target links; the +// host build supplies its own. +ColorVal UIColor::window_bkg = 0; +ColorVal UIColor::title_bkg = 0; +ColorVal UIColor::title_txt = 0; +ColorVal UIColor::primary_txt = 0; +ColorVal UIColor::secondary_txt = 0; +ColorVal UIColor::warning_txt = 0; +ColorVal UIColor::popup_bkg = 0; +ColorVal UIColor::popup_txt = 0; +ColorVal UIColor::corp_blue = 0; + +using namespace ObserverDashboard; + +namespace { + +const int N = RADIO_ACTIVITY_BUCKETS; + +struct Profile { + MockDisplay::Mode mode; + Layout layout; + const char* name; + int panel_w, panel_h; + int margin_left, margin_right; // physical +}; + +Profile portrait() { return {MockDisplay::PORTRAIT, portraitLayout(), "portrait", 240, 320, 7, 232}; } +Profile landscape() { return {MockDisplay::LANDSCAPE, landscapeLayout(), "landscape", 320, 240, 10, 310}; } + +Context makeContext(const char* name = "Ridgeline North") { + Context c; + c.node_name = name; + c.role_label = "REPEATER"; + c.freq = 910.525f; + c.sf = 7; + c.bw = 62.5f; + c.link_up = true; + return c; +} + +// A busy but plausible 20 minutes: 1843 packets, a peak minute of 214. +RadioActivitySnapshot makeBusy() { + RadioActivityWindow w; + w.reset(0); + const uint16_t per_minute[RADIO_ACTIVITY_BUCKETS] = {12, 40, 8, 0, 97, 133, 71, 3, 214, 65, + 19, 88, 44, 27, 0, 150, 92, 61, 7, 35}; + for (int m = 0; m < N; m++) { + for (int i = 0; i < per_minute[m]; i++) { + w.recordPacket((uint32_t)m * RADIO_ACTIVITY_BUCKET_MS + 1000 + i, 48, 120, 26, -103); + } + } + RadioActivitySnapshot s; + w.snapshot((uint32_t)(N - 1) * RADIO_ACTIVITY_BUCKET_MS + 30000, &s); + return s; +} + +RadioActivitySnapshot makeEmpty() { + RadioActivityWindow w; + w.reset(0); + RadioActivitySnapshot s; + w.snapshot(7 * RADIO_ACTIVITY_BUCKET_MS, &s); + return s; +} + +bool insideRect(const MockDisplay::Op& op, int x, int y, int w, int h) { + return op.x >= x && op.y >= y && op.x + op.w <= x + w && op.y + op.h <= y + h; +} + +} // namespace + +// ------------------------------------------------------------- formatting --- + +TEST(ObserverDashboardFormat, CompactCountsStayShortAtEveryMagnitude) { + char b[24]; + struct { uint32_t v; const char* want; } cases[] = { + {0, "0"}, {7, "7"}, {9999, "9999"}, {10000, "10.0k"}, + {12345, "12.3k"}, {99999, "99.9k"}, {100000, "100k"}, {999999, "999k"}, + {1000000, "1.0M"},{12345678, "12.3M"},{100000000, "100M"}}; + for (auto& c : cases) { + formatCompactCount(b, sizeof(b), c.v); + EXPECT_STREQ(c.want, b) << "value " << c.v; + EXPECT_LE(strlen(b), 5u) << "value " << c.v; + } +} + +TEST(ObserverDashboardFormat, CompactBytesPickSensibleUnits) { + char b[24]; + struct { uint32_t v; const char* want; } cases[] = { + {0, "0 B"}, {1023, "1023 B"}, {1024, "1.0 KB"}, + {10240, "10.0 KB"}, {145408, "142 KB"}, {1048576, "1.0 MB"}, + {15728640, "15.0 MB"}}; + for (auto& c : cases) { + formatCompactBytes(b, sizeof(b), c.v); + EXPECT_STREQ(c.want, b) << "value " << c.v; + } +} + +TEST(ObserverDashboardFormat, TenthsAndSignedTenths) { + char b[24]; + formatTenths(b, sizeof(b), 0); EXPECT_STREQ("0.0", b); + formatTenths(b, sizeof(b), 34); EXPECT_STREQ("3.4", b); + formatTenths(b, sizeof(b), 999); EXPECT_STREQ("99.9", b); + formatTenths(b, sizeof(b), 1000); EXPECT_STREQ("100", b); + + formatSignedTenths(b, sizeof(b), 72); EXPECT_STREQ("+7.2", b); + formatSignedTenths(b, sizeof(b), 0); EXPECT_STREQ("+0.0", b); + formatSignedTenths(b, sizeof(b), -115); EXPECT_STREQ("-11.5", b); +} + +TEST(ObserverDashboardFormat, AgeIsQuantisedToTheFiveSecondCadence) { + EXPECT_EQ(0u, quantizeAgeSecs(0)); + EXPECT_EQ(0u, quantizeAgeSecs(4999)); + EXPECT_EQ(5u, quantizeAgeSecs(5000)); + EXPECT_EQ(5u, quantizeAgeSecs(9999)); + + char b[24]; + formatAge(b, sizeof(b), 0, false); EXPECT_STREQ("--", b); + formatAge(b, sizeof(b), 0, true); EXPECT_STREQ("now", b); + formatAge(b, sizeof(b), 4999, true); EXPECT_STREQ("now", b); + formatAge(b, sizeof(b), 12000, true); EXPECT_STREQ("10s", b); + formatAge(b, sizeof(b), 59999, true); EXPECT_STREQ("55s", b); + formatAge(b, sizeof(b), 60000, true); EXPECT_STREQ("1m", b); + formatAge(b, sizeof(b), 3599999, true); EXPECT_STREQ("59m", b); + formatAge(b, sizeof(b), 3600000, true); EXPECT_STREQ("1h", b); +} + +TEST(ObserverDashboardFormat, EmptyWindowNeverProducesNanOrInfinity) { + RadioActivitySnapshot s = makeEmpty(); + Context ctx = makeContext(); + + for (int r = 0; r < ROW_COUNT; r++) { + if (r == ROW_GRAPH) continue; + RowText t; + composeRow((Row)r, ctx, s, &t); + for (const char* p : {t.left, t.right}) { + EXPECT_EQ(nullptr, strstr(p, "nan")) << p; + EXPECT_EQ(nullptr, strstr(p, "inf")) << p; + } + } + + // Unmeasurable values read as "--"; measured zeroes read as real zeroes. + RowText headline, rate, rf, status; + composeRow(ROW_HEADLINE, ctx, s, &headline); + composeRow(ROW_RATE, ctx, s, &rate); + composeRow(ROW_RF, ctx, s, &rf); + composeRow(ROW_STATUS, ctx, s, &status); + EXPECT_STREQ("No RF yet", headline.left); + EXPECT_STREQ("0 B", rate.left); + EXPECT_STREQ("0.0/min", rate.right); + EXPECT_STREQ("SNR --", rf.left); + EXPECT_STREQ("AIR 0.0%", rf.right); + EXPECT_STREQ("RX --", status.left); +} + +TEST(ObserverDashboardFormat, EveryRowFitsTheCharacterBudget) { + for (const Profile& p : {portrait(), landscape()}) { + for (const RadioActivitySnapshot& s : {makeBusy(), makeEmpty()}) { + for (int r = 0; r < ROW_COUNT; r++) { + if (r == ROW_GRAPH) continue; + RowText t; + composeRow((Row)r, makeContext(), s, &t); + int budget = (r == ROW_HEADLINE) ? p.layout.max_chars_big : p.layout.max_chars; + int used = (int)strlen(t.left) + (int)strlen(t.right); + if (t.left[0] && t.right[0]) used += 1; // separating space + EXPECT_LE(used, budget) << p.name << " row " << r << ": '" << t.left << "' / '" << t.right << "'"; + } + } + } +} + +TEST(ObserverDashboardFormat, RadioStripDropsADeadBandwidthDecimal) { + char b[32]; + formatRadioStrip(b, sizeof(b), 910.525f, 7, 62.5f); + EXPECT_STREQ("910.525 SF7 BW62.5", b); + formatRadioStrip(b, sizeof(b), 869.618f, 8, 250.0f); + EXPECT_STREQ("869.618 SF8 BW250", b); + formatRadioStrip(b, sizeof(b), 433.125f, 12, 125.0f); + EXPECT_STREQ("433.125 SF12 BW125", b); +} + +TEST(ObserverDashboardFormat, RadioStripFitsEveryOrientationsBudget) { + // The widest realistic combination must not be ellipsized away. + const struct { float freq; uint8_t sf; float bw; } cases[] = { + {910.525f, 7, 62.5f}, {869.618f, 8, 250.0f}, {433.125f, 12, 125.0f}, + {915.000f, 11, 500.0f}, {868.000f, 9, 41.7f}}; + for (const Profile& p : {portrait(), landscape()}) { + for (const auto& c : cases) { + char b[32]; + formatRadioStrip(b, sizeof(b), c.freq, c.sf, c.bw); + EXPECT_LE((int)strlen(b), p.layout.max_chars) << p.name << " '" << b << "'"; + } + } +} + +TEST(ObserverDashboardLayout, HeaderTextStaysInsideTheHeaderBar) { + for (const Profile& p : {portrait(), landscape()}) { + MockDisplay d(p.mode); + drawHeader(d, p.layout, makeContext()); + + ASSERT_FALSE(d.ops.empty()); + const auto& bar = d.ops.front(); + ASSERT_EQ(MockDisplay::Op::FILL, bar.kind) << p.name; + for (size_t i = 1; i < d.ops.size(); i++) { + EXPECT_TRUE(insideRect(d.ops[i], bar.x, bar.y, bar.w, bar.h)) + << p.name << " '" << d.ops[i].text << "'"; + } + } +} + +TEST(ObserverDashboardLayout, PortraitHeaderShowsTheWholeNodeName) { + // A 16-character name must survive intact: on a 240 px panel the role label + // moves to a second header line rather than eating the name. + MockDisplay d(MockDisplay::PORTRAIT); + Context ctx = makeContext("Ridgeline North"); + drawHeader(d, portraitLayout(), ctx); + + bool saw_name = false, saw_role = false; + for (const auto& op : d.ops) { + if (op.text == "Ridgeline North") saw_name = true; + if (op.text == "REPEATER") saw_role = true; + } + EXPECT_TRUE(saw_name) << "node name was ellipsized"; + EXPECT_TRUE(saw_role); +} + +TEST(ObserverDashboardFormat, TextIsAsciiOnly) { + // The driver's UTF-8 fallback collapses every non-ASCII byte to a full block, + // so any stray multi-byte character would render as a solid glyph. + for (const RadioActivitySnapshot& s : {makeBusy(), makeEmpty()}) { + for (int r = 0; r < ROW_COUNT; r++) { + if (r == ROW_GRAPH) continue; + RowText t; + composeRow((Row)r, makeContext(), s, &t); + for (const char* p : {t.left, t.right}) { + for (const char* c = p; *c; c++) { + EXPECT_GE((unsigned char)*c, 32u) << "row " << r; + EXPECT_LE((unsigned char)*c, 126u) << "row " << r; + } + } + } + } +} + +// ----------------------------------------------------------------- layout --- + +TEST(ObserverDashboardLayout, EveryDrawnPixelStaysOnThePanel) { + for (const Profile& p : {portrait(), landscape()}) { + MockDisplay d(p.mode); + drawFull(d, p.layout, makeContext(), makeBusy()); + ASSERT_FALSE(d.ops.empty()); + for (const auto& op : d.ops) { + EXPECT_GE(op.x, 0) << p.name; + EXPECT_GE(op.y, 0) << p.name; + EXPECT_LE(op.x + op.w, p.panel_w) << p.name << " '" << op.text << "'"; + EXPECT_LE(op.y + op.h, p.panel_h) << p.name << " '" << op.text << "'"; + } + } +} + +TEST(ObserverDashboardLayout, AllTextIsPaddedInsideTheMargins) { + for (const Profile& p : {portrait(), landscape()}) { + for (const RadioActivitySnapshot& s : {makeBusy(), makeEmpty()}) { + MockDisplay d(p.mode); + drawFull(d, p.layout, makeContext(), s); + for (const auto& op : d.ops) { + if (op.kind != MockDisplay::Op::TEXT) continue; + EXPECT_GE(op.x, p.margin_left) << p.name << " '" << op.text << "'"; + EXPECT_LE(op.x + op.w, p.margin_right) << p.name << " '" << op.text << "'"; + } + } + } +} + +TEST(ObserverDashboardLayout, NoTextSilentlyShrinksToTheFallbackScale) { + // The portrait driver halves the glyph size rather than clipping. A row that + // only fits because of that would break the grid, so it must never happen. + for (const RadioActivitySnapshot& s : {makeBusy(), makeEmpty()}) { + MockDisplay d(MockDisplay::PORTRAIT); + drawFull(d, portraitLayout(), makeContext(), s); + for (const auto& op : d.ops) { + EXPECT_FALSE(op.scale_fallback) << "'" << op.text << "'"; + } + } +} + +TEST(ObserverDashboardLayout, ContentClearsTheTopAndBottomEdges) { + for (const Profile& p : {portrait(), landscape()}) { + MockDisplay d(p.mode); + drawFull(d, p.layout, makeContext(), makeBusy()); + int lowest = 0; + for (const auto& op : d.ops) lowest = std::max(lowest, op.y + op.h); + EXPECT_GE(p.panel_h - lowest, 8) << p.name << ": bottom margin too small"; + } +} + +TEST(ObserverDashboardLayout, RowRectanglesDoNotOverlap) { + for (const Profile& p : {portrait(), landscape()}) { + MockDisplay d(p.mode); + for (int a = 0; a < ROW_COUNT; a++) { + for (int b = a + 1; b < ROW_COUNT; b++) { + int ay = p.layout.margin_x, unused = ay; + (void)unused; + int a_top = rowY(p.layout, (Row)a), a_bot = a_top + rowH(p.layout, (Row)a); + int b_top = rowY(p.layout, (Row)b), b_bot = b_top + rowH(p.layout, (Row)b); + bool overlap = a_top < b_bot && b_top < a_bot; + EXPECT_FALSE(overlap) << p.name << ": rows " << a << " and " << b; + } + } + // ...and the header and radio strip sit above the first row. + EXPECT_LT(p.layout.header_h, p.layout.radio_y) << p.name; + EXPECT_LT(p.layout.radio_y + p.layout.text_h, rowY(p.layout, ROW_WINDOW) + 1) << p.name; + } +} + +TEST(ObserverDashboardLayout, EachRowRepaintCoversEverythingThatRowDraws) { + // The no-flash invariant: a partial repaint clears one row rectangle and then + // redraws inside it. Anything drawn outside that rectangle would leave stale + // pixels behind or scribble on a neighbouring row. + for (const Profile& p : {portrait(), landscape()}) { + for (const RadioActivitySnapshot& s : {makeBusy(), makeEmpty()}) { + for (int r = 0; r < ROW_COUNT; r++) { + MockDisplay d(p.mode); + drawRow(d, p.layout, (Row)r, makeContext(), s, true); + ASSERT_FALSE(d.ops.empty()) << p.name << " row " << r; + + const auto& clear = d.ops.front(); + ASSERT_EQ(MockDisplay::Op::FILL, clear.kind) << p.name << " row " << r; + EXPECT_EQ(BG, clear.color) << p.name << " row " << r; + + for (size_t i = 1; i < d.ops.size(); i++) { + EXPECT_TRUE(insideRect(d.ops[i], clear.x, clear.y, clear.w, clear.h)) + << p.name << " row " << r << " op " << i << " '" << d.ops[i].text << "'"; + } + } + } + } +} + +// ------------------------------------------------------------------ graph --- + +TEST(ObserverDashboardGraph, DrawsExactlyTwentyNonOverlappingBars) { + for (const Profile& p : {portrait(), landscape()}) { + RadioActivitySnapshot s = makeBusy(); + for (int i = 0; i < N; i++) s.buckets[i] = (uint16_t)(i + 1); + s.peak_per_min = N; + + MockDisplay d(p.mode); + drawGraph(d, p.layout, s); + + // First op is the baseline, then one bar per non-empty bucket. + ASSERT_GE(d.ops.size(), 1u); + std::vector bars(d.ops.begin() + 1, d.ops.end()); + ASSERT_EQ((size_t)N, bars.size()) << p.name; + + for (size_t i = 1; i < bars.size(); i++) { + EXPECT_GE(bars[i].x, bars[i - 1].x + bars[i - 1].w) << p.name << " bar " << i << " overlaps"; + EXPECT_GE(bars[i].h, bars[i - 1].h) << p.name << " bar " << i << " not monotonic"; + } + EXPECT_EQ(BAR_NOW, bars.back().color) << p.name << ": current minute must stand out"; + EXPECT_EQ(BAR, bars.front().color) << p.name; + } +} + +TEST(ObserverDashboardGraph, BarsStayInsideTheGraphRectangle) { + for (const Profile& p : {portrait(), landscape()}) { + RadioActivitySnapshot s = makeBusy(); + MockDisplay probe(p.mode); + probe.setColor(0); + probe.fillRect(p.layout.margin_x, p.layout.graph_y, p.layout.right_x - p.layout.margin_x, + p.layout.graph_h); + MockDisplay::Op rect = probe.ops.front(); + + MockDisplay d(p.mode); + drawGraph(d, p.layout, s); + for (const auto& op : d.ops) { + EXPECT_TRUE(insideRect(op, rect.x, rect.y, rect.w, rect.h)) << p.name; + } + } +} + +TEST(ObserverDashboardGraph, AllZeroWindowDrawsOnlyTheBaseline) { + for (const Profile& p : {portrait(), landscape()}) { + MockDisplay d(p.mode); + drawGraph(d, p.layout, makeEmpty()); + ASSERT_EQ(1u, d.ops.size()) << p.name << ": empty minutes must not draw one-pixel activity"; + EXPECT_EQ(GRID, d.ops.front().color) << p.name; + } +} + +TEST(ObserverDashboardGraph, SingleSpikeFillsTheGraphAndLeavesTheRestEmpty) { + for (const Profile& p : {portrait(), landscape()}) { + RadioActivitySnapshot s = makeEmpty(); + s.buckets[5] = 400; + s.peak_per_min = 400; + s.packets = 400; + + uint8_t h[RADIO_ACTIVITY_BUCKETS]; + barHeights(p.layout, s, h); + EXPECT_EQ(p.layout.graph_h - 1, h[5]) << p.name; + for (int i = 0; i < N; i++) { + if (i != 5) EXPECT_EQ(0, h[i]) << p.name << " bucket " << i; + } + + MockDisplay d(p.mode); + drawGraph(d, p.layout, s); + EXPECT_EQ(2u, d.ops.size()) << p.name; // baseline + one bar + } +} + +TEST(ObserverDashboardGraph, AnyTrafficRoundsUpToAVisibleBar) { + for (const Profile& p : {portrait(), landscape()}) { + RadioActivitySnapshot s = makeEmpty(); + s.buckets[0] = 1; + s.buckets[19] = 5000; + s.peak_per_min = 5000; + + uint8_t h[RADIO_ACTIVITY_BUCKETS]; + barHeights(p.layout, s, h); + EXPECT_EQ(1, h[0]) << p.name << ": a single packet must still be visible"; + EXPECT_EQ(p.layout.graph_h - 1, h[19]) << p.name; + } +} + +// ------------------------------------------------------------- signatures --- + +TEST(ObserverDashboardSignature, IdenticallyFormattedDataDoesNotRepaint) { + Layout l = portraitLayout(); + Context ctx = makeContext(); + + RadioActivitySnapshot a = makeEmpty(); + a.packets = 100000; + RadioActivitySnapshot b = a; + b.packets = 100999; // both render as "100k pkt" + + EXPECT_EQ(rowSignature(l, ROW_HEADLINE, ctx, a), rowSignature(l, ROW_HEADLINE, ctx, b)); + + b.packets = 101500; // renders as "101k pkt" + EXPECT_NE(rowSignature(l, ROW_HEADLINE, ctx, a), rowSignature(l, ROW_HEADLINE, ctx, b)); +} + +TEST(ObserverDashboardSignature, AGraphChangeTouchesOnlyTheGraphRow) { + Layout l = portraitLayout(); + Context ctx = makeContext(); + + RadioActivitySnapshot a = makeBusy(); + RadioActivitySnapshot b = a; + b.buckets[N - 1] = (uint16_t)(a.buckets[N - 1] + 40); // the current minute grows + + uint32_t sa[ROW_COUNT], sb[ROW_COUNT]; + allRowSignatures(l, ctx, a, sa); + allRowSignatures(l, ctx, b, sb); + + for (int r = 0; r < ROW_COUNT; r++) { + if (r == ROW_GRAPH) { + EXPECT_NE(sa[r], sb[r]) << "graph row must notice the new bar height"; + } else { + EXPECT_EQ(sa[r], sb[r]) << "row " << r << " must not repaint"; + } + } +} + +TEST(ObserverDashboardSignature, DataChangesBelowTheGraphResolutionDoNotRepaint) { + // Signatures are computed from bar heights, not from the packet counts behind + // them, so a busy minute ticking up by one costs nothing on screen. + Layout l = portraitLayout(); + Context ctx = makeContext(); + + RadioActivitySnapshot a = makeBusy(); + RadioActivitySnapshot b = a; + b.buckets[N - 1] = (uint16_t)(a.buckets[N - 1] + 1); + + uint8_t ha[RADIO_ACTIVITY_BUCKETS], hb[RADIO_ACTIVITY_BUCKETS]; + barHeights(l, a, ha); + barHeights(l, b, hb); + ASSERT_EQ(ha[N - 1], hb[N - 1]) << "test needs a change smaller than one bar unit"; + + EXPECT_EQ(rowSignature(l, ROW_GRAPH, ctx, a), rowSignature(l, ROW_GRAPH, ctx, b)); +} + +TEST(ObserverDashboardSignature, LinkStateOnlyTouchesTheStatusRow) { + Layout l = portraitLayout(); + RadioActivitySnapshot s = makeBusy(); + + Context up = makeContext(); + Context down = makeContext(); + down.link_up = false; + + uint32_t sa[ROW_COUNT], sb[ROW_COUNT]; + allRowSignatures(l, up, s, sa); + allRowSignatures(l, down, s, sb); + + for (int r = 0; r < ROW_COUNT; r++) { + if (r == ROW_STATUS) { + EXPECT_NE(sa[r], sb[r]); + } else { + EXPECT_EQ(sa[r], sb[r]) << "row " << r; + } + } +} + +TEST(ObserverDashboardSignature, PartialRepaintDrawsOnlyTheChangedRow) { + Profile p = portrait(); + Context ctx = makeContext(); + RadioActivitySnapshot a = makeBusy(); + + uint32_t sigs[ROW_COUNT]; + allRowSignatures(p.layout, ctx, a, sigs); + + RadioActivitySnapshot b = a; + b.buckets[N - 1] = (uint16_t)(a.buckets[N - 1] + 40); + + MockDisplay d(p.mode); + EXPECT_TRUE(drawChangedRows(d, p.layout, ctx, b, sigs)); + + // One clear plus the graph contents, all inside the graph rectangle. + ASSERT_FALSE(d.ops.empty()); + const auto& clear = d.ops.front(); + EXPECT_EQ(BG, clear.color); + for (const auto& op : d.ops) { + EXPECT_TRUE(insideRect(op, clear.x, clear.y, clear.w, clear.h)); + } + + // Nothing left to do on a second pass with the same data. + MockDisplay d2(p.mode); + EXPECT_FALSE(drawChangedRows(d2, p.layout, ctx, b, sigs)); + EXPECT_TRUE(d2.ops.empty()); +} + +TEST(ObserverDashboardSignature, LongNodeNameIsTrimmedInsideTheHeader) { + for (const Profile& p : {portrait(), landscape()}) { + MockDisplay d(p.mode); + Context ctx = makeContext("A Very Long Repeater Node Name That Cannot Possibly Fit"); + drawHeader(d, p.layout, ctx); + + bool saw_role = false, saw_ellipsis = false; + for (const auto& op : d.ops) { + if (op.kind != MockDisplay::Op::TEXT) continue; + EXPECT_GE(op.x, p.margin_left) << p.name; + EXPECT_LE(op.x + op.w, p.margin_right) << p.name << " '" << op.text << "'"; + EXPECT_FALSE(op.scale_fallback) << p.name << " '" << op.text << "'"; + if (op.text == "REPEATER") saw_role = true; + if (op.text.size() >= 3 && op.text.compare(op.text.size() - 3, 3, "...") == 0) + saw_ellipsis = true; + } + EXPECT_TRUE(saw_role) << p.name << ": the role label must survive a long node name"; + EXPECT_TRUE(saw_ellipsis) << p.name << ": the name must be visibly truncated"; + } +} + +TEST(ObserverDashboardSignature, FitToCharsRespectsItsBudget) { + char b[32]; + fitToChars(b, sizeof(b), "short", 18); EXPECT_STREQ("short", b); + fitToChars(b, sizeof(b), "exactly-18-chars!", 17); EXPECT_STREQ("exactly-18-chars!", b); + fitToChars(b, sizeof(b), "Ridgeline North Ridge", 18); + EXPECT_STREQ("Ridgeline North...", b); + EXPECT_EQ(18u, strlen(b)); + fitToChars(b, sizeof(b), "abcdef", 3); EXPECT_STREQ("abc", b); + fitToChars(b, sizeof(b), "abcdef", 0); EXPECT_STREQ("", b); + fitToChars(b, sizeof(b), "", 18); EXPECT_STREQ("", b); +} + +TEST(ObserverDashboardSignature, DarkPaletteRetunesTheSharedColourSlots) { + applyDarkPalette(); + EXPECT_EQ(BG, UIColor::window_bkg); + EXPECT_EQ(TEXT, UIColor::primary_txt); + EXPECT_EQ(HEADER_BG, UIColor::title_bkg); + EXPECT_EQ(ACCENT, UIColor::corp_blue); + // The setup portal's highlight must stay legible on the dark background. + EXPECT_NE(UIColor::window_bkg, UIColor::warning_txt); + EXPECT_NE(UIColor::window_bkg, UIColor::primary_txt); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_radio_activity_window/test_radio_activity_window.cpp b/test/test_radio_activity_window/test_radio_activity_window.cpp new file mode 100644 index 00000000..65f953e4 --- /dev/null +++ b/test/test_radio_activity_window/test_radio_activity_window.cpp @@ -0,0 +1,376 @@ +#include "helpers/RadioActivityWindow.h" + +#include + +namespace { + +const uint32_t MINUTE = RADIO_ACTIVITY_BUCKET_MS; +const int N = RADIO_ACTIVITY_BUCKETS; + +// Representative packet: 32 wire bytes, 100 ms airtime, +7.0 dB SNR, -95 dBm. +void recordTypical(RadioActivityWindow& w, uint32_t at_ms, uint16_t bytes = 32) { + w.recordPacket(at_ms, bytes, 100, 28, -95); +} + +RadioActivitySnapshot snapshotAt(RadioActivityWindow& w, uint32_t at_ms) { + RadioActivitySnapshot s; + w.snapshot(at_ms, &s); + return s; +} + +} // namespace + +TEST(RadioActivityWindow, EmptySnapshotHasNoTotalsAndNoDivisionByZero) { + RadioActivityWindow w; + w.reset(0); + + RadioActivitySnapshot s = snapshotAt(w, 0); + + EXPECT_TRUE(s.isEmpty()); + EXPECT_EQ(0u, s.packets); + EXPECT_EQ(0u, s.wire_bytes); + EXPECT_EQ(0u, s.window_ms); + EXPECT_FALSE(s.has_last_packet); + EXPECT_EQ(0u, s.peak_per_min); + + // Every derived value must be defined with a zero denominator. + EXPECT_EQ(0u, s.packetsPerMinuteX10()); + EXPECT_EQ(0u, s.bytesPerSecondX10()); + EXPECT_EQ(0u, s.avgBytesPerPacket()); + EXPECT_EQ(0u, s.airtimePercentX10()); + EXPECT_EQ(0, s.avgSnrX10()); + EXPECT_EQ(0, s.avgRssi()); + + for (int i = 0; i < N; i++) EXPECT_EQ(0u, s.buckets[i]); +} + +TEST(RadioActivityWindow, SingleEventProducesExactTotalsAndRates) { + RadioActivityWindow w; + w.reset(0); + recordTypical(w, 1000); + + RadioActivitySnapshot s = snapshotAt(w, 2000); + + EXPECT_EQ(1u, s.packets); + EXPECT_EQ(32u, s.wire_bytes); + EXPECT_EQ(100u, s.airtime_ms); + EXPECT_EQ(2000u, s.window_ms); + EXPECT_EQ(2000u, s.tracking_ms); + + EXPECT_EQ(300u, s.packetsPerMinuteX10()); // 30.0 packets/min + EXPECT_EQ(160u, s.bytesPerSecondX10()); // 16.0 B/s + EXPECT_EQ(32u, s.avgBytesPerPacket()); + EXPECT_EQ(50u, s.airtimePercentX10()); // 5.0 % + EXPECT_EQ(70, s.avgSnrX10()); // +7.0 dB + EXPECT_EQ(-95, s.avgRssi()); + + EXPECT_TRUE(s.has_last_packet); + EXPECT_EQ(1000u, s.last_packet_age_ms); + + // The current minute is the rightmost bucket. + EXPECT_EQ(1u, s.buckets[N - 1]); + for (int i = 0; i < N - 1; i++) EXPECT_EQ(0u, s.buckets[i]); +} + +TEST(RadioActivityWindow, MultipleEventsInOneMinuteAccumulate) { + RadioActivityWindow w; + w.reset(0); + recordTypical(w, 1000, 10); + recordTypical(w, 2000, 20); + recordTypical(w, 3000, 30); + + RadioActivitySnapshot s = snapshotAt(w, 4000); + + EXPECT_EQ(3u, s.packets); + EXPECT_EQ(60u, s.wire_bytes); + EXPECT_EQ(300u, s.airtime_ms); + EXPECT_EQ(20u, s.avgBytesPerPacket()); + EXPECT_EQ(3u, s.buckets[N - 1]); + EXPECT_EQ(3u, s.peak_per_min); + EXPECT_EQ(1000u, s.last_packet_age_ms); +} + +TEST(RadioActivityWindow, EventsRotateIntoTheNextBucketAtTheMinuteBoundary) { + RadioActivityWindow w; + w.reset(0); + recordTypical(w, 30000); // minute 0 + recordTypical(w, MINUTE); // exactly on the boundary: minute 1 + recordTypical(w, MINUTE + 5000); // minute 1 + + RadioActivitySnapshot s = snapshotAt(w, MINUTE + 10000); + + EXPECT_EQ(3u, s.packets); + EXPECT_EQ(2u, s.buckets[N - 1]); // current minute + EXPECT_EQ(1u, s.buckets[N - 2]); // previous minute + EXPECT_EQ(2u, s.peak_per_min); +} + +TEST(RadioActivityWindow, BucketsAreOrderedOldestToNewest) { + RadioActivityWindow w; + w.reset(0); + + // Minute m gets (m + 1) packets. + for (int m = 0; m < N; m++) { + for (int i = 0; i <= m; i++) recordTypical(w, m * MINUTE + 1000 + i); + } + + RadioActivitySnapshot s = snapshotAt(w, (N - 1) * MINUTE + 30000); + + for (int i = 0; i < N; i++) { + EXPECT_EQ((uint16_t)(i + 1), s.buckets[i]) << "bucket " << i; + } + EXPECT_EQ((uint16_t)N, s.peak_per_min); + EXPECT_EQ((uint32_t)(N * (N + 1) / 2), s.packets); +} + +TEST(RadioActivityWindow, OldestBucketExpiresOnceItLeavesTheWindow) { + RadioActivityWindow w; + w.reset(0); + + for (int m = 0; m < N; m++) recordTypical(w, m * MINUTE + 1000); + + // Still inside the window: all 20 minutes are represented. + RadioActivitySnapshot before = snapshotAt(w, (N - 1) * MINUTE + 59999); + EXPECT_EQ((uint32_t)N, before.packets); + EXPECT_EQ(1u, before.buckets[0]); + + // One tick past the boundary: the oldest minute is gone, and the new current + // minute is empty. + RadioActivitySnapshot after = snapshotAt(w, N * MINUTE); + EXPECT_EQ((uint32_t)(N - 1), after.packets); + EXPECT_EQ(1u, after.buckets[0]); // what was minute 1 + EXPECT_EQ(0u, after.buckets[N - 1]); // the fresh current minute +} + +TEST(RadioActivityWindow, MoreThanTwentyMinutesOfSilenceClearsTheRing) { + RadioActivityWindow w; + w.reset(0); + recordTypical(w, 1000); + + uint32_t now = 21 * MINUTE; + RadioActivitySnapshot s = snapshotAt(w, now); + + EXPECT_TRUE(s.isEmpty()); + for (int i = 0; i < N; i++) EXPECT_EQ(0u, s.buckets[i]); + + // Tracking restarts at the current minute, so the window reports itself as + // warming up again rather than claiming 20 minutes of empty coverage. + EXPECT_EQ(0u, s.tracking_ms); + EXPECT_EQ(0u, s.window_ms); + EXPECT_TRUE(s.isWarmingUp()); + + // The last-packet age survives the ring clear: it is still the most useful + // thing to show when nothing is arriving. + EXPECT_TRUE(s.has_last_packet); + EXPECT_EQ(now - 1000, s.last_packet_age_ms); +} + +TEST(RadioActivityWindow, LastPacketAgeIsDroppedOnceItGoesStale) { + RadioActivityWindow w; + w.reset(0); + recordTypical(w, 1000); + + RadioActivitySnapshot fresh = snapshotAt(w, 1000 + RADIO_ACTIVITY_MAX_AGE_MS); + EXPECT_TRUE(fresh.has_last_packet); + + RadioActivitySnapshot stale = snapshotAt(w, 1000 + RADIO_ACTIVITY_MAX_AGE_MS + 1); + EXPECT_FALSE(stale.has_last_packet); +} + +TEST(RadioActivityWindow, WarmupUsesObservedDurationNotAFixedTwentyMinutes) { + RadioActivityWindow w; + w.reset(0); + recordTypical(w, 30000); + + // Five minutes in, rates are computed against five minutes, not twenty. + RadioActivitySnapshot warm = snapshotAt(w, 5 * MINUTE); + EXPECT_TRUE(warm.isWarmingUp()); + EXPECT_EQ(5u, warm.warmupMinutes()); + EXPECT_EQ(5 * MINUTE, warm.window_ms); + // 1 packet over 5 minutes is 0.2/min. Against a fixed 1200 s denominator the + // same data would round away to 0.0/min. + EXPECT_EQ(2u, warm.packetsPerMinuteX10()); +} + +TEST(RadioActivityWindow, SteadyStateWindowNeverClaimsMoreCoverageThanTheRingHas) { + RadioActivityWindow w; + w.reset(0); + for (int m = 0; m < 25; m++) recordTypical(w, m * MINUTE + 1000); + + // 19 whole minutes plus the elapsed part of the current one - never 20:00. + RadioActivitySnapshot at_start = snapshotAt(w, 25 * MINUTE); + EXPECT_FALSE(at_start.isWarmingUp()); + EXPECT_EQ(19 * MINUTE, at_start.window_ms); + + RadioActivitySnapshot mid = snapshotAt(w, 25 * MINUTE + 30000); + EXPECT_EQ(19 * MINUTE + 30000, mid.window_ms); + + RadioActivitySnapshot late = snapshotAt(w, 25 * MINUTE + 59999); + EXPECT_EQ(19 * MINUTE + 59999, late.window_ms); + EXPECT_LT(late.window_ms, (uint32_t)N * MINUTE); +} + +TEST(RadioActivityWindow, PeakIsTheBusiestVisibleMinute) { + RadioActivityWindow w; + w.reset(0); + recordTypical(w, 1000); + for (int i = 0; i < 7; i++) recordTypical(w, MINUTE + 1000 + i); + recordTypical(w, 2 * MINUTE + 1000); + + EXPECT_EQ(7u, snapshotAt(w, 2 * MINUTE + 30000).peak_per_min); + + // Once the busy minute ages out of the ring, so does the peak. + EXPECT_EQ(1u, snapshotAt(w, 21 * MINUTE).peak_per_min); +} + +TEST(RadioActivityWindow, SurvivesMillisRollover) { + const uint32_t base = 0xFFFFF000u; // ~4 s before the 32-bit wrap + RadioActivityWindow w; + w.reset(base); + + recordTypical(w, base + 1000); + + // 65 s later, which is 60904 in wrapped millis(). + uint32_t after_wrap = (uint32_t)(base + 65000); + ASSERT_LT(after_wrap, base) << "test setup must actually cross the wrap"; + recordTypical(w, after_wrap); + + RadioActivitySnapshot s = snapshotAt(w, after_wrap + 1000); + + EXPECT_EQ(2u, s.packets); + EXPECT_EQ(1u, s.buckets[N - 1]); // the post-wrap minute + EXPECT_EQ(1u, s.buckets[N - 2]); // the pre-wrap minute + EXPECT_EQ(66000u, s.window_ms); + EXPECT_EQ(1000u, s.last_packet_age_ms); +} + +TEST(RadioActivityWindow, RolloverDoesNotCorruptTheMinuteBoundary) { + // A boundary derived from now_ms / BUCKET_MS would misplace a minute here, + // because 2^32 is not a whole number of 60000 ms buckets. + const uint32_t base = 0xFFFFFFFFu - 30000u; + RadioActivityWindow w; + w.reset(base); + + for (int m = 0; m < 5; m++) recordTypical(w, (uint32_t)(base + m * MINUTE + 1000)); + + RadioActivitySnapshot s = snapshotAt(w, (uint32_t)(base + 4 * MINUTE + 30000)); + + EXPECT_EQ(5u, s.packets); + for (int i = 0; i < 5; i++) { + EXPECT_EQ(1u, s.buckets[N - 1 - i]) << "minute -" << i; + } + EXPECT_EQ(1u, s.peak_per_min); +} + +TEST(RadioActivityWindow, SurvivesAFullMillisCycleOfContinuousUptime) { + // The always-on dashboard services the tracker every few seconds forever. Past + // 2^32 ms (~49.7 days) a 32-bit tracker age wraps back to a small value, which + // would drop the window into warm-up and divide 20 minutes of traffic by + // seconds - inflating every rate on screen. + RadioActivityWindow w; + w.reset(0); + + const uint32_t STEP = 30000; // two packets per minute bucket + uint32_t now = 0; + for (uint64_t elapsed = 0; elapsed < 0x100000000ull + 10 * MINUTE; elapsed += STEP) { + recordTypical(w, now); + RadioActivitySnapshot tick; + w.snapshot(now, &tick); + now += STEP; + } + + RadioActivitySnapshot s = snapshotAt(w, now); + + EXPECT_FALSE(s.isWarmingUp()) << "must not fall back into warm-up after the wrap"; + EXPECT_GE(s.window_ms, 19 * MINUTE); + EXPECT_LE(s.window_ms, (uint32_t)N * MINUTE); + + // 19 whole minutes at two packets each, plus however much of the current + // minute has elapsed. + EXPECT_GE(s.packets, 38u); + EXPECT_LE(s.packets, 41u); + // Two packets a minute, and it must still read as two. + EXPECT_GE(s.packetsPerMinuteX10(), 15u); + EXPECT_LE(s.packetsPerMinuteX10(), 25u); + + EXPECT_TRUE(s.has_last_packet); + EXPECT_EQ(STEP, s.last_packet_age_ms); +} + +TEST(RadioActivityWindow, StaleLastPacketDoesNotComeBackAfterTheWrap) { + RadioActivityWindow w; + w.reset(0); + recordTypical(w, 1000); + + // Serviced continuously, but silent, for more than one full 32-bit cycle. + uint32_t now = 0; + const uint32_t STEP = 60000; + for (uint64_t elapsed = 0; elapsed < 0x100000000ull + 10 * MINUTE; elapsed += STEP) { + RadioActivitySnapshot tick; + w.snapshot(now, &tick); + if (elapsed > RADIO_ACTIVITY_MAX_AGE_MS) { + ASSERT_FALSE(tick.has_last_packet) << "a stale age must never look fresh again"; + } + now += STEP; + } + + RadioActivitySnapshot s = snapshotAt(w, now); + EXPECT_TRUE(s.isEmpty()); + EXPECT_FALSE(s.has_last_packet); +} + +TEST(RadioActivityWindow, SaturatedMinuteDropsFurtherEventsWhole) { + RadioActivityWindow w; + w.reset(0); + + for (uint32_t i = 0; i < 65535; i++) w.recordPacket(1000, 10, 1, 4, -100); + + RadioActivitySnapshot full = snapshotAt(w, 2000); + EXPECT_EQ(65535u, full.packets); + EXPECT_EQ(655350u, full.wire_bytes); + + // Past saturation nothing is counted, so bytes-per-packet stays truthful. + w.recordPacket(1500, 10, 1, 4, -100); + RadioActivitySnapshot after = snapshotAt(w, 2000); + EXPECT_EQ(65535u, after.packets); + EXPECT_EQ(655350u, after.wire_bytes); + EXPECT_EQ(10u, after.avgBytesPerPacket()); +} + +TEST(RadioActivityWindow, AnOlderTimestampDoesNotExpireTheWindow) { + // recordPacket() and snapshot() read millis() at slightly different moments; + // a reading that arrives out of order must cost nothing. + RadioActivityWindow w; + w.reset(0); + recordTypical(w, 5000); + + RadioActivitySnapshot ahead = snapshotAt(w, 10000); + ASSERT_EQ(1u, ahead.packets); + + recordTypical(w, 9000); // stale reading, 1 s behind the last snapshot + RadioActivitySnapshot s = snapshotAt(w, 10000); + + EXPECT_EQ(2u, s.packets) << "the ring must not have been cleared"; + EXPECT_EQ(2u, s.buckets[N - 1]); + EXPECT_EQ(10000u, s.window_ms); +} + +TEST(RadioActivityWindow, AveragesHandleNegativeSnrAndMixedSigns) { + RadioActivityWindow w; + w.reset(0); + w.recordPacket(1000, 40, 50, 28, -80); // +7.0 dB + w.recordPacket(1100, 40, 50, -28, -120); // -7.0 dB + + RadioActivitySnapshot s = snapshotAt(w, 2000); + EXPECT_EQ(0, s.avgSnrX10()); + EXPECT_EQ(-100, s.avgRssi()); +} + +TEST(RadioActivityWindow, StaysWithinItsMemoryBudget) { + EXPECT_LE(sizeof(RadioActivityWindow), 1024u); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_touch_tap_detector/test_touch_tap_detector.cpp b/test/test_touch_tap_detector/test_touch_tap_detector.cpp new file mode 100644 index 00000000..656b5b42 --- /dev/null +++ b/test/test_touch_tap_detector/test_touch_tap_detector.cpp @@ -0,0 +1,123 @@ +#include "helpers/ui/TouchTapDetector.h" + +#include + +namespace { + +// Drives the detector at the firmware's 50 ms poll cadence. +const uint32_t POLL = 50; + +// Holds `pressed` for `ms`, returning how many taps were accepted. +int hold(TouchTapDetector& d, uint32_t& now, bool pressed, uint32_t ms) { + int taps = 0; + for (uint32_t t = 0; t < ms; t += POLL) { + if (d.update(now, pressed)) taps++; + now += POLL; + } + return taps; +} + +} // namespace + +TEST(TouchTapDetector, IdlePanelNeverTaps) { + TouchTapDetector d; + uint32_t now = 1000; + d.reset(now); + EXPECT_EQ(0, hold(d, now, false, 5000)); + EXPECT_FALSE(d.isTouched()); +} + +TEST(TouchTapDetector, OneTouchProducesExactlyOneTap) { + TouchTapDetector d; + uint32_t now = 1000; + d.reset(now); + + EXPECT_EQ(1, hold(d, now, true, 300)); + EXPECT_TRUE(d.isTouched()); + EXPECT_EQ(0, hold(d, now, false, 300)); + EXPECT_FALSE(d.isTouched()); +} + +TEST(TouchTapDetector, HoldingAFingerDownDoesNotRepeat) { + TouchTapDetector d; + uint32_t now = 1000; + d.reset(now); + + EXPECT_EQ(1, hold(d, now, true, 100)); + EXPECT_EQ(0, hold(d, now, true, 10000)) << "a long press must not toggle repeatedly"; +} + +TEST(TouchTapDetector, ContactShorterThanTheDebounceWindowIsIgnored) { + TouchTapDetector d; + uint32_t now = 1000; + d.reset(now); + + // A single 30 ms blip, below TOUCH_TAP_DEBOUNCE_MS. + EXPECT_FALSE(d.update(now, true)); + now += 30; + EXPECT_FALSE(d.update(now, false)); + now += 30; + EXPECT_EQ(0, hold(d, now, false, 500)); +} + +TEST(TouchTapDetector, BounceOnContactStillCountsAsOneTap) { + TouchTapDetector d; + uint32_t now = 1000; + d.reset(now); + + int taps = 0; + for (int i = 0; i < 6; i++) { // chattering edge + if (d.update(now, i % 2 == 0)) taps++; + now += 10; + } + taps += hold(d, now, true, 200); // then settles down + EXPECT_EQ(1, taps); +} + +TEST(TouchTapDetector, SecondTapTooSoonIsSuppressed) { + TouchTapDetector d; + uint32_t now = 1000; + d.reset(now); + + EXPECT_EQ(1, hold(d, now, true, 100)); + EXPECT_EQ(0, hold(d, now, false, 100)); + EXPECT_EQ(0, hold(d, now, true, 100)) << "inside TOUCH_TAP_MIN_GAP_MS"; +} + +TEST(TouchTapDetector, DeliberateSecondTapIsAccepted) { + TouchTapDetector d; + uint32_t now = 1000; + d.reset(now); + + EXPECT_EQ(1, hold(d, now, true, 100)); + EXPECT_EQ(0, hold(d, now, false, 600)); // past the min gap + EXPECT_EQ(1, hold(d, now, true, 100)); +} + +TEST(TouchTapDetector, SurvivesMillisRollover) { + TouchTapDetector d; + const uint32_t start = 0xFFFFFF9Bu; // ~100 ms short of the 32-bit wrap + uint32_t now = start; + d.reset(now); + + EXPECT_EQ(1, hold(d, now, true, 200)); // the touch itself crosses the wrap + ASSERT_LT(now, start) << "test setup must actually wrap"; + EXPECT_EQ(0, hold(d, now, false, 600)); + EXPECT_EQ(1, hold(d, now, true, 200)); +} + +TEST(TouchTapDetector, ResetClearsPendingState) { + TouchTapDetector d; + uint32_t now = 1000; + d.reset(now); + EXPECT_EQ(1, hold(d, now, true, 200)); + + d.reset(now); + EXPECT_FALSE(d.isTouched()); + EXPECT_EQ(1, hold(d, now, true, 200)) << "a fresh probe starts clean"; +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.cpp b/variants/heltec_v4_r8/HeltecV4R8Board.cpp index 34d6b992..9a07db92 100644 --- a/variants/heltec_v4_r8/HeltecV4R8Board.cpp +++ b/variants/heltec_v4_r8/HeltecV4R8Board.cpp @@ -12,16 +12,15 @@ void HeltecV4R8Board::begin() { loRaFEMControl.init(); - // GPIO 21 is shared by LCD_RST and TP_RST. Let ST7789LCDDisplay own the - // reset sequence; no separate touch reset is needed. -#ifdef PIN_TOUCH_RST - pinMode(PIN_TOUCH_RST, OUTPUT); - digitalWrite(PIN_TOUCH_RST, HIGH); - delay(10); - digitalWrite(PIN_TOUCH_RST, LOW); - delay(100); - digitalWrite(PIN_TOUCH_RST, HIGH); -#endif + // Expansion Kit V2 display/touch pins, verified against Heltec's + // Expansion_board_V2.03 schematic and the V4-R8 datasheet pinout: + // GPIO 17/18 TP_SDA / TP_SCL - the module's OLED_SDA/OLED_SCL I2C bus + // GPIO 21 LCD_RST *and* TP_RST on one net (the module's OLED_RST) + // GPIO 43 TP_INT, optional via R13, and also U0TXD + // GPIO 44 LCD_LEDK backlight, also U0RXD + // ST7789LCDDisplay owns GPIO 21, so there is no separate touch reset to do + // here - and because that net is shared, it must not be parked low while the + // display is off or the touch controller is held in reset with it. esp_reset_reason_t reason = esp_reset_reason(); if (reason == ESP_RST_DEEPSLEEP) { diff --git a/variants/heltec_v4_r8/platformio.ini b/variants/heltec_v4_r8/platformio.ini index 9025d422..ec509b0a 100644 --- a/variants/heltec_v4_r8/platformio.ini +++ b/variants/heltec_v4_r8/platformio.ini @@ -335,6 +335,14 @@ build_flags = -D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm -D WITH_SNMP=1 -D DISPLAY_REDRAW_ON_CHANGE=1 + -D DISPLAY_ACTIVITY_DASHBOARD=1 +; Blanking is a runtime setting now: `set display.timeout `, 0 = stay on, +; 60 s default. Tap the Expansion Kit panel or press USER to toggle by hand. + -D DISPLAY_TOUCH_TOGGLE=1 +; Diagnostic: logs the raw CHSC6X frame (and TP_INT) when it changes. Drop this +; and PIN_TOUCH_INT once touch is confirmed working. + -D DISPLAY_TOUCH_DEBUG=1 + -D PIN_TOUCH_INT=43 build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + + @@ -402,6 +410,14 @@ build_flags = -D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm -D WITH_SNMP=1 -D DISPLAY_REDRAW_ON_CHANGE=1 + -D DISPLAY_ACTIVITY_DASHBOARD=1 +; Blanking is a runtime setting now: `set display.timeout `, 0 = stay on, +; 60 s default. Tap the Expansion Kit panel or press USER to toggle by hand. + -D DISPLAY_TOUCH_TOGGLE=1 +; Diagnostic: logs the raw CHSC6X frame (and TP_INT) when it changes. Drop this +; and PIN_TOUCH_INT once touch is confirmed working. + -D DISPLAY_TOUCH_DEBUG=1 + -D PIN_TOUCH_INT=43 build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + +