mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-26 15:17:54 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user