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 <secs>` / `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.
This commit is contained in:
agessaman
2026-08-28 13:39:16 -07:00
parent fc361ca94b
commit fcd92e985f
27 changed files with 2709 additions and 25 deletions
+5
View File
@@ -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);
+15
View File
@@ -35,6 +35,10 @@
#include "helpers/SNMPAgent.h"
#endif
#ifdef DISPLAY_ACTIVITY_DASHBOARD
#include <helpers/RadioActivityWindow.h>
#endif
#include <helpers/AdvertDataHelpers.h>
#include <helpers/AlertReporter.h>
#include <helpers/ArduinoHelpers.h>
@@ -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);
}
+149 -6
View File
@@ -16,7 +16,31 @@
#include <helpers/esp32/WebConfigServer.h> // 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
}
}
+48
View File
@@ -3,6 +3,19 @@
#include <helpers/ui/DisplayDriver.h>
#include <helpers/CommonCLI.h>
#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 <helpers/RadioActivityWindow.h>
#include <helpers/ui/ObserverDashboard.h>
#endif
#ifdef DISPLAY_TOUCH_TOGGLE
#include <helpers/ui/CHSC6XTouch.h>
#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();
};
+6
View File
@@ -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
+5
View File
@@ -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);
+15
View File
@@ -15,6 +15,10 @@
#include <helpers/StaticPoolPacketManager.h>
#include <helpers/SimpleMeshTables.h>
#include <helpers/IdentityStore.h>
#ifdef DISPLAY_ACTIVITY_DASHBOARD
#include <helpers/RadioActivityWindow.h>
#endif
#include <helpers/AdvertDataHelpers.h>
#include <helpers/AlertReporter.h>
#include <helpers/TxtDataHelpers.h>
@@ -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);
}
+149 -6
View File
@@ -15,7 +15,31 @@
#include <helpers/esp32/WebConfigServer.h> // 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
}
}
+48
View File
@@ -3,6 +3,19 @@
#include <helpers/ui/DisplayDriver.h>
#include <helpers/CommonCLI.h>
#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 <helpers/RadioActivityWindow.h>
#include <helpers/ui/ObserverDashboard.h>
#endif
#ifdef DISPLAY_TOUCH_TOGGLE
#include <helpers/ui/CHSC6XTouch.h>
#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();
};
+6
View File
@@ -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
+28 -1
View File
@@ -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];
+2
View File
@@ -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
+22 -1
View File
@@ -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<uint16_t>(_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;
}
+8
View File
@@ -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
+207
View File
@@ -0,0 +1,207 @@
#pragma once
#include <stdint.h>
#include <string.h>
// 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");
+108
View File
@@ -0,0 +1,108 @@
#pragma once
#include <Arduino.h>
#include <Wire.h>
#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
};
+524
View File
@@ -0,0 +1,524 @@
#pragma once
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#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
+6 -1
View File
@@ -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;
+61
View File
@@ -0,0 +1,61 @@
#pragma once
#include <stdint.h>
// 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;
};
+3
View File
@@ -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)
@@ -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();
+122
View File
@@ -0,0 +1,122 @@
#pragma once
#include "helpers/ui/DisplayDriver.h"
#include "helpers/ui/DisplayViewport.h"
#include <string>
#include <vector>
// 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<Op> 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); }
};
@@ -0,0 +1,581 @@
#include "MockDisplay.h"
#include "helpers/ui/ObserverDashboard.h"
#include <gtest/gtest.h>
// 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<MockDisplay::Op> 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();
}
@@ -0,0 +1,376 @@
#include "helpers/RadioActivityWindow.h"
#include <gtest/gtest.h>
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();
}
@@ -0,0 +1,123 @@
#include "helpers/ui/TouchTapDetector.h"
#include <gtest/gtest.h>
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();
}
+9 -10
View File
@@ -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) {
+16
View File
@@ -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 <secs>`, 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}
+<helpers/bridges/MQTTBridge.cpp>
+<helpers/MQTTMessageBuilder.cpp>
@@ -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 <secs>`, 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}
+<helpers/bridges/MQTTBridge.cpp>
+<helpers/MQTTMessageBuilder.cpp>