mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-26 15:17:54 +00:00
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.
404 lines
13 KiB
C++
404 lines
13 KiB
C++
#include "UITask.h"
|
|
#include <Arduino.h>
|
|
#include <helpers/CommonCLI.h>
|
|
|
|
#ifdef DISPLAY_REDRAW_ON_CHANGE
|
|
#include <helpers/ui/DisplayFrameSignature.h>
|
|
#endif
|
|
|
|
#ifndef USER_BTN_PRESSED
|
|
#define USER_BTN_PRESSED LOW
|
|
#endif
|
|
|
|
#ifdef WITH_MQTT_BRIDGE
|
|
#include <WiFi.h>
|
|
#include <helpers/esp32/WebConfigServer.h> // defines WITH_WEBCONFIG on ESP32
|
|
#endif
|
|
|
|
#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
|
|
static const uint8_t meshcore_logo [] PROGMEM = {
|
|
0x3c, 0x01, 0xe3, 0xff, 0xc7, 0xff, 0x8f, 0x03, 0x87, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe,
|
|
0x3c, 0x03, 0xe3, 0xff, 0xc7, 0xff, 0x8e, 0x03, 0x8f, 0xfe, 0x3f, 0xfe, 0x1f, 0xff, 0x1f, 0xfe,
|
|
0x3e, 0x03, 0xc3, 0xff, 0x8f, 0xff, 0x0e, 0x07, 0x8f, 0xfe, 0x7f, 0xfe, 0x1f, 0xff, 0x1f, 0xfc,
|
|
0x3e, 0x07, 0xc7, 0x80, 0x0e, 0x00, 0x0e, 0x07, 0x9e, 0x00, 0x78, 0x0e, 0x3c, 0x0f, 0x1c, 0x00,
|
|
0x3e, 0x0f, 0xc7, 0x80, 0x1e, 0x00, 0x0e, 0x07, 0x1e, 0x00, 0x70, 0x0e, 0x38, 0x0f, 0x3c, 0x00,
|
|
0x7f, 0x0f, 0xc7, 0xfe, 0x1f, 0xfc, 0x1f, 0xff, 0x1c, 0x00, 0x70, 0x0e, 0x38, 0x0e, 0x3f, 0xf8,
|
|
0x7f, 0x1f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x0e, 0x38, 0x0e, 0x3f, 0xf8,
|
|
0x7f, 0x3f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x1e, 0x3f, 0xfe, 0x3f, 0xf0,
|
|
0x77, 0x3b, 0x87, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xfc, 0x38, 0x00,
|
|
0x77, 0xfb, 0x8f, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xf8, 0x38, 0x00,
|
|
0x73, 0xf3, 0x8f, 0xff, 0x0f, 0xff, 0x1c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x78, 0x7f, 0xf8,
|
|
0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfe, 0x3c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x3c, 0x7f, 0xf8,
|
|
0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfc, 0x3c, 0x0e, 0x1f, 0xf8, 0xff, 0xf8, 0x70, 0x3c, 0x7f, 0xf8,
|
|
};
|
|
|
|
void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) {
|
|
_prevBtnState = HIGH;
|
|
_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
|
|
|
|
// strip off dash and commit hash by changing dash to null terminator
|
|
// e.g: v1.2.3-abcdef -> v1.2.3
|
|
char *version = strdup(firmware_version);
|
|
char *dash = strchr(version, '-');
|
|
if(dash){
|
|
*dash = 0;
|
|
}
|
|
|
|
// v1.2.3 (1 Jan 2025)
|
|
snprintf(_version_info, sizeof(_version_info), "%s (%s)", version, build_date);
|
|
free(version);
|
|
}
|
|
|
|
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);
|
|
int logoWidth = 128;
|
|
_display->drawXbm((_display->width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13);
|
|
|
|
// meshcore website
|
|
const char* website = "https://meshcore.io";
|
|
_display->setColor(UIColor::primary_txt);
|
|
_display->setTextSize(1);
|
|
uint16_t websiteWidth = _display->getTextWidth(website);
|
|
_display->setCursor((_display->width() - websiteWidth) / 2, 22);
|
|
_display->print(website);
|
|
|
|
// version info
|
|
_display->setTextSize(1);
|
|
uint16_t versionWidth = _display->getTextWidth(_version_info);
|
|
_display->setCursor((_display->width() - versionWidth) / 2, 35);
|
|
_display->print(_version_info);
|
|
|
|
// node type
|
|
const char* node_type = "< Room Server >";
|
|
uint16_t typeWidth = _display->getTextWidth(node_type);
|
|
_display->setCursor((_display->width() - typeWidth) / 2, 48);
|
|
_display->print(node_type);
|
|
} else { // home screen
|
|
#ifdef WITH_WEBCONFIG
|
|
if (WebConfigServer::isRebootPending()) {
|
|
// save confirmed on-device: show ground truth even if the browser
|
|
// lost its connection before the confirmation reached it
|
|
_display->setTextSize(1);
|
|
_display->setColor(UIColor::corp_blue);
|
|
_display->setCursor(0, 14);
|
|
_display->print("Config saved!");
|
|
_display->setColor(UIColor::primary_txt);
|
|
_display->setCursor(0, 30);
|
|
_display->print("Rebooting...");
|
|
return;
|
|
}
|
|
char wc_ssid[33], wc_ip[16];
|
|
if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) {
|
|
// setup portal active: show join instructions instead of the home screen
|
|
_display->setTextSize(1);
|
|
_display->setColor(UIColor::corp_blue);
|
|
_display->setCursor(0, 0);
|
|
_display->print("Observer WiFi Setup");
|
|
|
|
_display->setColor(UIColor::primary_txt);
|
|
_display->setCursor(0, 14);
|
|
_display->print("Join WiFi:");
|
|
_display->setColor(UIColor::warning_txt);
|
|
_display->setCursor(6, 24);
|
|
_display->print(wc_ssid);
|
|
|
|
_display->setColor(UIColor::primary_txt);
|
|
_display->setCursor(0, 40);
|
|
_display->print("Then browse to:");
|
|
_display->setColor(UIColor::warning_txt);
|
|
_display->setCursor(6, 50);
|
|
_display->print(wc_ip);
|
|
return;
|
|
}
|
|
#endif
|
|
#ifdef DISPLAY_ACTIVITY_DASHBOARD
|
|
renderDashboard();
|
|
return;
|
|
#endif
|
|
// node name
|
|
_display->setCursor(0, 0);
|
|
_display->setTextSize(1);
|
|
_display->setColor(UIColor::primary_txt);
|
|
_display->print(_node_prefs->node_name);
|
|
|
|
// freq / sf
|
|
_display->setCursor(0, 20);
|
|
sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf);
|
|
_display->print(tmp);
|
|
|
|
// bw / cr
|
|
_display->setCursor(0, 30);
|
|
sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr);
|
|
_display->print(tmp);
|
|
|
|
#ifdef WITH_MQTT_BRIDGE
|
|
// Display IP address for MQTT bridge devices
|
|
if (WiFi.status() == WL_CONNECTED) {
|
|
IPAddress ip = WiFi.localIP();
|
|
_display->setCursor(0, 40);
|
|
_display->setColor(UIColor::primary_txt);
|
|
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
|
|
_display->print(tmp);
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
#ifdef DISPLAY_REDRAW_ON_CHANGE
|
|
uint32_t UITask::getFrameSignature() {
|
|
uint32_t signature = DisplayFrameSignature::INITIAL;
|
|
char tmp[80];
|
|
|
|
if (millis() < BOOT_SCREEN_MILLIS) {
|
|
signature = DisplayFrameSignature::append(signature, "boot");
|
|
return DisplayFrameSignature::append(signature, _version_info);
|
|
}
|
|
|
|
#ifdef WITH_WEBCONFIG
|
|
if (WebConfigServer::isRebootPending()) {
|
|
return DisplayFrameSignature::append(signature, "rebooting");
|
|
}
|
|
|
|
char wc_ssid[33], wc_ip[16];
|
|
if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) {
|
|
signature = DisplayFrameSignature::append(signature, "setup");
|
|
signature = DisplayFrameSignature::append(signature, wc_ssid);
|
|
return DisplayFrameSignature::append(signature, wc_ip);
|
|
}
|
|
#endif
|
|
|
|
signature = DisplayFrameSignature::append(signature, "home");
|
|
signature = DisplayFrameSignature::append(signature, _node_prefs->node_name);
|
|
snprintf(tmp, sizeof(tmp), "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf);
|
|
signature = DisplayFrameSignature::append(signature, tmp);
|
|
snprintf(tmp, sizeof(tmp), "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr);
|
|
signature = DisplayFrameSignature::append(signature, tmp);
|
|
|
|
#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]);
|
|
signature = DisplayFrameSignature::append(signature, tmp);
|
|
} else {
|
|
signature = DisplayFrameSignature::append(signature, "wifi-disconnected");
|
|
}
|
|
#endif
|
|
|
|
return signature;
|
|
}
|
|
#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() + displayTimeoutMillis(); // extend auto-off timer
|
|
#endif
|
|
}
|
|
_prevBtnState = btnState;
|
|
}
|
|
_next_read = millis() + 200; // 5 reads per second
|
|
}
|
|
#endif
|
|
|
|
#ifdef WITH_WEBCONFIG
|
|
// While the setup portal is up there's no user button to wake the screen
|
|
// reliably - keep it on so the join instructions stay visible.
|
|
if (WebConfigServer::getSetupInfo(NULL, 0, NULL, 0)) {
|
|
if (!_display->isOn()) {
|
|
_display->turnOn();
|
|
#ifdef DISPLAY_REDRAW_ON_CHANGE
|
|
_frame_valid = false;
|
|
#endif
|
|
#ifdef DISPLAY_ACTIVITY_DASHBOARD
|
|
_rows_valid = false;
|
|
#endif
|
|
}
|
|
_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
|
|
|
|
if (_display->isOn()) {
|
|
if (millis() >= _next_refresh) {
|
|
bool redraw = true;
|
|
#ifdef DISPLAY_REDRAW_ON_CHANGE
|
|
uint32_t frame_signature = getFrameSignature();
|
|
redraw = !_frame_valid || frame_signature != _last_frame_signature;
|
|
#endif
|
|
if (redraw) {
|
|
_display->startFrame();
|
|
renderCurrScreen();
|
|
_display->endFrame();
|
|
#ifdef DISPLAY_REDRAW_ON_CHANGE
|
|
_last_frame_signature = frame_signature;
|
|
_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
|
|
}
|
|
// `_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
|
|
}
|
|
}
|
|
}
|