Files
MeshCore/examples/simple_repeater/UITask.cpp
T
9d52b1fd68 Add saved LED controls across supported boards
Add get leds and set leds on|off for Companion, repeater, room server and
sensor builds. Preserve the setting across reboots and cover GPIO, RGB,
Bluetooth, notification and expander indicators without changing radio
amplifier controls or screen backlights.

RAK Companions blink while advertising and keep the Bluetooth LED off once
connected, including late timer callbacks.

Adapted from meshcore-dev/MeshCore#3605 and #2694.
Validated with host regressions and firmware builds for SenseCAP Solar,
RAK4631, T096, Heltec V4, T-Beam, Xiao S3, Pico W and Wio E5 Mini.

Co-authored-by: R0ck <develop@hectospark.co.uk>
Co-authored-by: Nick Dunklee <nick@dunklee.org>
2026-10-20 08:59:39 -07:00

993 lines
37 KiB
C++

#include "UITask.h"
#include "target.h"
#include <Arduino.h>
#include <helpers/UsbLogging.h>
#include <helpers/MorseLoopPolicy.h>
#include <helpers/CommonCLI.h>
#include <helpers/ui/WiFiSetupQrDisplay.h>
#include <helpers/ui/CompanionHomeLayout.h>
#include <helpers/ui/RadioProfileDisplayPage.h>
#include <RadioProfiles.h>
#include <Utils.h>
#include "MyMesh.h"
extern MyMesh the_mesh;
namespace {
const mesh::RadioProfiles* configuredRadioProfiles() {
const auto* radio = the_mesh.getProfileRadio();
return radio ? radio->profiles() : NULL;
}
const char* secondaryRadioProfileTag() {
const auto* profiles = configuredRadioProfiles();
return mesh::ui::radioProfileDisplayTag(true,
profiles != NULL && profiles->secondary_temporary);
}
// The complete system-status view needs four rows on a single-radio node and
// two more when radio2 is configured. Grouping related switches keeps a
// normal 128x64 OLED to one status page (rather than silently dropping the
// lower rows), while the measured page count below naturally splits this on
// a genuinely short display.
constexpr uint8_t RADIO_SYSTEM_STATUS_ROWS = 8;
constexpr uint8_t RADIO_SYSTEM_STATUS_ROWS_WITHOUT_RADIO2 = 5;
uint8_t radioSystemStatusRowsPerPage(DisplayDriver& display) {
const int line_height = display.textLineHeight();
if (line_height <= 0) return 1;
const int row_height = line_height + 2;
const int rows = (display.height() + 2) / row_height;
return rows > 0 ? rows : 1;
}
uint8_t radioSystemStatusPageCount(DisplayDriver& display,
bool dual_radio_enabled) {
const uint8_t row_count = dual_radio_enabled ? RADIO_SYSTEM_STATUS_ROWS
: RADIO_SYSTEM_STATUS_ROWS_WITHOUT_RADIO2;
const uint8_t rows_per_page = radioSystemStatusRowsPerPage(display);
return (row_count + rows_per_page - 1) / rows_per_page;
}
int drawRadioStatusState(DisplayDriver& display, int x, int y,
const char* label, bool enabled) {
const char* state = enabled ? "ON" : "OFF";
display.setColor(UIColor::primary_txt);
display.setCursor(x, y);
display.print(label);
x += display.getTextWidth(label);
display.setColor(enabled ? UIColor::primary_txt : UIColor::warning_txt);
display.setCursor(x, y);
display.print(state);
return x + display.getTextWidth(state);
}
void drawRadioStatusPair(DisplayDriver& display, int y,
const char* first_label, bool first_enabled,
const char* second_label, bool second_enabled) {
int x = drawRadioStatusState(display, 0, y, first_label, first_enabled);
drawRadioStatusState(display, x + display.getTextWidth(" "), y,
second_label, second_enabled);
display.setColor(UIColor::primary_txt);
}
void formatRadioNoiseFloor(char* noise_floor, size_t size, const char* label,
uint8_t profile = 0) {
const float dbm = radio_driver.getNoiseFloorDbm(profile);
if (dbm == 0.0f) {
const float seconds = radio_driver.getNoiseFloorCalibrationSecondsRemaining(profile);
if (seconds > 0.0f) {
snprintf(noise_floor, size, "%s:%.1fs", label, seconds);
} else {
snprintf(noise_floor, size, "%s:WAIT", label);
}
} else {
snprintf(noise_floor, size, "%s:%.1f", label, dbm);
}
}
void drawRadioNoiseFloor(DisplayDriver& display, int y, const char* label,
uint8_t profile = 0) {
char noise_floor[12];
formatRadioNoiseFloor(noise_floor, sizeof(noise_floor), label, profile);
display.setColor(UIColor::primary_txt);
display.setCursor(0, y);
display.print(noise_floor);
}
void drawRadioSystemStatusPage(DisplayDriver& display, const NodePrefs& prefs,
uint8_t status_page_index) {
display.setTextSize(1);
const auto* profiles = configuredRadioProfiles();
const bool dual_radio = profiles != NULL && profiles->enabled();
const uint8_t rows_per_page = radioSystemStatusRowsPerPage(display);
const uint8_t first_row = status_page_index * rows_per_page;
const uint8_t row_count = dual_radio ? RADIO_SYSTEM_STATUS_ROWS
: RADIO_SYSTEM_STATUS_ROWS_WITHOUT_RADIO2;
const int row_height = display.textLineHeight() + 2;
for (uint8_t row = first_row; row < row_count
&& row < first_row + rows_per_page; ++row) {
const int y = (row - first_row) * row_height;
switch (row) {
case 0: {
char identity_prefix[7];
mesh::Utils::toHex(identity_prefix, the_mesh.getSelfId().pub_key, 3);
display.setColor(UIColor::primary_txt);
display.setCursor(0, y);
display.print("ID:");
display.print(identity_prefix);
break;
}
case 1:
#if ENV_INCLUDE_GPS == 1
drawRadioStatusPair(display, y, "PS:", prefs.powersaving_enabled != 0,
"GPS:", prefs.gps_enabled != 0);
#else
drawRadioStatusPair(display, y, "PS:", prefs.powersaving_enabled != 0,
"GPS:", false);
#endif
break;
case 2:
drawRadioStatusPair(display, y, "FEM:",
board.canControlLoRaFemLna() && board.isLoRaFemLnaEnabled(),
"RXB:", prefs.rx_boosted_gain != 0);
break;
case 3:
drawRadioStatusPair(display, y, "RXPS:", prefs.rx_powersaving_enabled != 0,
"CAD:", prefs.cad_enabled != 0);
break;
case 4: {
char mode[10];
snprintf(mode, sizeof(mode), "%s:%s", secondaryRadioProfileTag(),
profiles->secondary.mode == mesh::RadioProfileMode::RxTx ? "RXTX" : "RX");
display.setColor(UIColor::primary_txt);
display.setCursor(0, y);
display.print(mode);
break;
}
case 5: {
const char* cross = profiles->cross == mesh::RadioCrossMode::On ? "X:ON"
: profiles->cross == mesh::RadioCrossMode::Off ? "X:OFF" : "X:AUTO";
display.setColor(UIColor::primary_txt);
display.setCursor(0, y);
display.print(cross);
break;
}
case 6:
drawRadioNoiseFloor(display, y, "N1", 0);
break;
case 7:
drawRadioNoiseFloor(display, y, "N2", 1);
break;
}
}
display.setColor(UIColor::primary_txt);
}
#if defined(HELTEC_T096)
// The native T096 canvas is 160x80. Compact F/B/S/C rows use the first 108
// pixels; this leaves a 50px status column plus a 2px right margin. The
// widest normal 6x8 label, "RXPS:OFF", is 48px, so it has a 2px buffer.
constexpr int T096_STATUS_PANEL_WIDTH = 50;
constexpr int T096_STATUS_RIGHT_MARGIN = 2;
constexpr int T096_STATUS_TOP = 10;
constexpr int T096_STATUS_LINE_HEIGHT = 10;
void drawT096State(DisplayDriver& display, int x, int y,
const char* label, bool enabled) {
const char* state = enabled ? "ON" : "OFF";
display.setColor(UIColor::primary_txt);
display.setCursor(x, y);
display.print(label);
display.setColor(enabled ? UIColor::primary_txt : UIColor::warning_txt);
display.setCursor(x + display.getTextWidth(label), y);
display.print(state);
}
void drawT096StatusRow(DisplayDriver& display, int y,
const char* name, bool enabled) {
char label[8]; // "RXPS:" plus its terminator
snprintf(label, sizeof(label), "%s:", name);
const char* state = enabled ? "ON" : "OFF";
const int x = display.width() - T096_STATUS_RIGHT_MARGIN
- display.getTextWidth(label) - display.getTextWidth(state);
drawT096State(display, x, y, label, enabled);
}
void drawT096NoiseFloorRow(DisplayDriver& display, int y, uint8_t profile) {
char noise_floor[12];
const bool dual_radio = the_mesh.isDualRadioActive();
const char* label = dual_radio ? (profile == 1 ? "N2" : "N1") : "N";
// "N1:-120.0" needs 54px in the normal 6px font. On this final line the
// left side contains only the short cross-mode marker, so it may safely use
// four pixels of the otherwise empty gutter while retaining decimal dBm.
formatRadioNoiseFloor(noise_floor, sizeof(noise_floor), label, profile);
display.setColor(UIColor::primary_txt);
display.setCursor(display.width() - T096_STATUS_RIGHT_MARGIN
- display.getTextWidth(noise_floor), y);
display.print(noise_floor);
}
void drawT096StatusPanel(DisplayDriver& display, const NodePrefs& prefs,
uint8_t profile) {
// This is the ST7735 driver's standard 6x8 font, rather than Squeezed6.
display.setTextSize(1);
int y = T096_STATUS_TOP;
#if ENV_INCLUDE_GPS == 1
drawT096StatusRow(display, y, "GPS", prefs.gps_enabled != 0);
#else
drawT096StatusRow(display, y, "GPS", false);
#endif
y += T096_STATUS_LINE_HEIGHT;
drawT096StatusRow(display, y, "FEM", board.canControlLoRaFemLna()
&& board.isLoRaFemLnaEnabled());
y += T096_STATUS_LINE_HEIGHT;
drawT096StatusRow(display, y, "RXB", prefs.rx_boosted_gain != 0);
y += T096_STATUS_LINE_HEIGHT;
drawT096StatusRow(display, y, "RXPS", prefs.rx_powersaving_enabled != 0);
// A configured secondary profile keeps both profiles live; show it only
// when it exists, so the panel does not imply a second radio on every T096.
if (the_mesh.isDualRadioActive()) {
y += T096_STATUS_LINE_HEIGHT;
drawT096StatusRow(display, y, secondaryRadioProfileTag(), true);
}
y += T096_STATUS_LINE_HEIGHT;
drawT096StatusRow(display, y, "CAD", prefs.cad_enabled != 0);
y += T096_STATUS_LINE_HEIGHT;
drawT096NoiseFloorRow(display, y, profile);
display.setColor(UIColor::primary_txt);
}
void drawT096Radio2Details(DisplayDriver& display, int mode_y, int cross_y) {
const auto* profiles = configuredRadioProfiles();
if (profiles == NULL || !profiles->enabled()) return;
char mode[10];
snprintf(mode, sizeof(mode), "%s:%s", secondaryRadioProfileTag(),
profiles->secondary.mode == mesh::RadioProfileMode::RxTx ? "RXTX" : "RX");
const char* cross = profiles->cross == mesh::RadioCrossMode::On ? "X:ON"
: profiles->cross == mesh::RadioCrossMode::Off ? "X:OFF" : "X:AUTO";
display.setColor(UIColor::primary_txt);
display.setCursor(0, mode_y);
display.print(mode);
display.setCursor(0, cross_y);
display.print(cross);
}
#endif // HELTEC_T096
} // namespace
#ifdef DISPLAY_REDRAW_ON_CHANGE
#include <helpers/ui/DisplayFrameSignature.h>
#endif
#ifndef USER_BTN_PRESSED
#define USER_BTN_PRESSED LOW
#endif
#ifdef ESP_PLATFORM
#include <WiFi.h>
#include <helpers/esp32/WebConfigServer.h> // defines WITH_WEBCONFIG on ESP32
#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;
}
#if defined(HELTEC_V4_R8_TFT) && defined(HELTEC_TFT_RUNTIME_ROTATION) && HELTEC_TFT_RUNTIME_ROTATION == 1
#include <helpers/ui/DisplayRotationSettings.h>
void UITask::applyDisplayRotation() {
if (_display == nullptr || !_display->supportsRotation()) return;
const uint16_t degrees = mesh::ui::displayRotationDegrees();
if (degrees == _rotation_seen || !_display->setRotationDegrees(degrees)) return;
_rotation_seen = degrees;
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false;
#endif
#ifdef DISPLAY_ACTIVITY_DASHBOARD
_rows_valid = false;
#endif
_next_refresh = millis();
}
#endif
// Applies `display.flip` when it changes, forcing a complete repaint because
// the panel's existing contents are now the wrong way up.
void UITask::applyDisplayFlip() {
#ifdef WITH_MQTT_BRIDGE
if (_observer_prefs == NULL || _observer_prefs->display_flip == _flip_seen) return;
_flip_seen = _observer_prefs->display_flip;
_display->setFlipped(_flip_seen != 0);
// Logged unconditionally: this is persisted config, so it survives a reflash
// and is otherwise invisible when someone is chasing a wrong orientation.
mesh::usbConsolePort().printf("Display: flip %s\n", _flip_seen ? "on (rotated 180)" : "off");
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false;
#endif
#ifdef DISPLAY_ACTIVITY_DASHBOARD
_rows_valid = false;
#endif
_next_refresh = 0;
#endif
}
#define BOOT_SCREEN_MILLIS 4000 // 4 seconds
#define POWEROFF_DELAY 3000
// '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;
_started_at = millis();
_startup_complete = false;
_node_prefs = node_prefs;
resetRadioProfileDisplayPage();
#ifdef DISPLAY_ACTIVITY_DASHBOARD
ObserverDashboard::applyDarkPalette(); // retunes UIColor for this target only
#endif
#if defined(HELTEC_V4_R8_TFT) && defined(HELTEC_TFT_RUNTIME_ROTATION) && HELTEC_TFT_RUNTIME_ROTATION == 1
applyDisplayRotation();
#endif
_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected());
applyDisplayFlip();
#ifdef DISPLAY_TOUCH_TOGGLE
_touch.begin();
#endif
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false;
#endif
#if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS)
user_btn.begin();
#endif
// strip off dash and commit hash without allocating a mutable version copy
// e.g: v1.2.3-abcdef -> v1.2.3
size_t version_len = 0;
while (version_len < sizeof(_version_info) - 1
&& firmware_version[version_len] && firmware_version[version_len] != '-') {
++version_len;
}
// v1.2.3 (1 Jan 2025)
snprintf(_version_info, sizeof(_version_info), "%.*s (%s)",
static_cast<int>(version_len), firmware_version, build_date);
}
void UITask::resetRadioProfileDisplayPage() {
_radio_profile_display_page = 0;
_dual_radio_enabled_seen = the_mesh.isDualRadioActive();
}
uint8_t UITask::currentRadioProfileDisplayPage() {
const bool dual_radio_enabled = the_mesh.isDualRadioActive();
if (dual_radio_enabled != _dual_radio_enabled_seen) {
_dual_radio_enabled_seen = dual_radio_enabled;
_radio_profile_display_page = 0;
}
return mesh::ui::radioProfileManualPageIndex(dual_radio_enabled,
radioProfileSystemStatusPageCount(), _radio_profile_display_page);
}
void UITask::advanceRadioProfileDisplayPage() {
const bool dual_radio_enabled = the_mesh.isDualRadioActive();
const uint8_t page_count = mesh::ui::radioProfileDisplayPageCount(
dual_radio_enabled, radioProfileSystemStatusPageCount());
_radio_profile_display_page = (currentRadioProfileDisplayPage() + 1) % page_count;
_next_refresh = 0;
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false;
#endif
#ifdef DISPLAY_ACTIVITY_DASHBOARD
_rows_valid = false;
#endif
}
bool UITask::showingSecondaryRadioProfilePage() {
return mesh::ui::showManualSecondaryRadioProfilePage(the_mesh.isDualRadioActive(),
currentRadioProfileDisplayPage());
}
uint8_t UITask::radioProfileSystemStatusPageCount() const {
#if defined(HELTEC_T096)
// The T096's dedicated 50px status column already shows every switch.
return 0;
#else
_display->setTextSize(1);
return radioSystemStatusPageCount(*_display, the_mesh.isDualRadioActive());
#endif
}
bool UITask::showingRadioProfileSystemStatusPage(uint8_t* status_page_index) {
return mesh::ui::showManualRadioProfileSystemStatusPage(the_mesh.isDualRadioActive(),
radioProfileSystemStatusPageCount(), currentRadioProfileDisplayPage(),
status_page_index);
}
void UITask::renderCurrScreen() {
char tmp[80];
#ifdef DISPLAY_ACTIVITY_DASHBOARD
_rows_valid = false;
#endif
if (!_startup_complete && (uint32_t)(millis() - _started_at) < 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);
_display->drawTextCentered(_display->width() / 2, 22, website);
// version info
_display->setTextSize(1);
_display->drawTextCentered(_display->width() / 2, 35, _version_info);
// node type
const char* node_type = "< Repeater >";
_display->drawTextCentered(_display->width() / 2, 48, node_type);
} else if (_powering_off_at > 0) {
// 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);
_display->drawTextCentered(_display->width()/ 2, 22, website);
// Powering off
const char* poweroff_string = "Turning OFF";
uint16_t poffWidth = _display->getTextWidth(poweroff_string);
_display->setCursor((_display->width() - poffWidth) / 2, 48);
_display->drawTextCentered(_display->width()/2, 48, poweroff_string);
} 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
if (mesh::ui::drawWiFiSetupQr(*_display, wc_ssid, wc_ip)) {
return;
}
_display->setTextSize(1);
_display->setColor(UIColor::corp_blue);
_display->setCursor(0, 0);
#ifdef DISPLAY_ACTIVITY_DASHBOARD
_display->print("Observer WiFi Setup");
#else
_display->print("WebUI WiFi Setup");
#endif
_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
uint8_t status_page_index = 0;
if (showingRadioProfileSystemStatusPage(&status_page_index)) {
drawRadioSystemStatusPage(*_display, *_node_prefs, status_page_index);
return;
}
#ifdef DISPLAY_ACTIVITY_DASHBOARD
renderDashboard();
return;
#endif
// Reserve a full measured font-height for each row on OLED/TFT/e-paper.
#if defined(HELTEC_T096)
// Never let normal full-size rows overwrite the dedicated normal-font
// column at the right of the native 160x80 T096 screen.
const int content_width = _display->width() - T096_STATUS_PANEL_WIDTH
- T096_STATUS_RIGHT_MARGIN;
#else
const int content_width = _display->width();
#endif
_display->setTextSize(1);
_display->setColor(UIColor::primary_txt);
#if defined(HELTEC_T096)
// The name gets the entire top row. The status strip begins below it, so
// no part of the name is covered or forced into a second line.
_display->drawTextEllipsized(0, 0, _display->width(), _node_prefs->node_name);
const int rows_top = T096_STATUS_TOP;
mesh::ui::BoundedTextRows rows(*_display,
{0, rows_top, content_width, _display->height() - rows_top});
#else
mesh::ui::BoundedTextRows rows(*_display,
{0, 0, content_width, _display->height()});
rows.draw(_node_prefs->node_name, false);
#endif
const mesh::RadioProfiles* profiles = configuredRadioProfiles();
const bool secondary_page = showingSecondaryRadioProfilePage();
const bool dual_radio = profiles != NULL && profiles->enabled();
float freq = _node_prefs->freq;
float bw = _node_prefs->bw;
uint8_t sf = _node_prefs->sf;
uint8_t cr = _node_prefs->cr;
const char* profile_tag = "";
if (dual_radio) {
const uint8_t profile = secondary_page ? 1 : 0;
const auto& params = profiles->params(profile);
freq = params.freq;
bw = params.bw;
sf = params.sf;
cr = params.cr;
profile_tag = mesh::ui::radioProfileDisplayTag(profile,
profile == 1 ? profiles->secondary_temporary : profiles->primary_temporary);
}
// T096's normal 6x8 right column needs compact radio labels. Other
// displays retain the longer labels for their roomier layouts.
#if defined(HELTEC_T096)
snprintf(tmp, sizeof(tmp), "%s%sF:%06.3f S:%d", profile_tag,
dual_radio ? " " : "", freq, sf);
#else
if (dual_radio) {
// R1/R2/T1/T2 needs three extra cells. Compact fields retain every RF
// value on a 128px OLED even at the widest legal frequency/SF pair.
snprintf(tmp, sizeof(tmp), "%s F:%06.3f S:%d", profile_tag, freq, sf);
} else {
snprintf(tmp, sizeof(tmp), "FREQ: %06.3f SF%d", freq, sf);
}
#endif
rows.draw(tmp, false);
// bandwidth / coding rate
#if defined(HELTEC_T096)
snprintf(tmp, sizeof(tmp), "%s%sB:%03.2f C:%d", profile_tag,
dual_radio ? " " : "", bw, cr);
#else
if (dual_radio) {
snprintf(tmp, sizeof(tmp), "%s B:%03.2f C:%d", profile_tag, bw, cr);
} else {
snprintf(tmp, sizeof(tmp), "BW: %03.2f CR: %d", bw, cr);
}
#endif
rows.draw(tmp, false);
#ifdef WITH_MQTT_BRIDGE
// Display IP address for MQTT bridge devices
if (WiFi.status() == WL_CONNECTED) {
IPAddress ip = WiFi.localIP();
_display->setColor(UIColor::primary_txt);
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
rows.draw(tmp, false);
} else
#endif
{
snprintf(tmp, sizeof(tmp), "BAT: %.2fV", _board->getBattMilliVolts() / 1000.0f);
rows.draw(tmp, false);
}
// Keep power-saving state visible even when the MQTT IP replaces battery.
#if defined(HELTEC_T096)
// This is the fourth left-column row (after F, B, and battery/IP). Keep
// its label green, with just the saved ON/OFF value carrying the state
// color used by the status strip.
const int power_saving_y = rows.nextY();
if (rows.reserve()) {
drawT096State(*_display, 0, power_saving_y, "PS: ",
_node_prefs->powersaving_enabled != 0);
}
const int identity_y = rows.nextY();
if (rows.reserve()) {
char identity_prefix[7];
mesh::Utils::toHex(identity_prefix, the_mesh.getSelfId().pub_key, 3);
_display->setColor(UIColor::primary_txt);
_display->setCursor(0, identity_y);
_display->print("ID:");
_display->print(identity_prefix);
}
drawT096Radio2Details(*_display, rows.nextY(),
rows.nextY() + _display->textLineHeight() + 2);
#else
snprintf(tmp, sizeof(tmp), "PS: %s", _node_prefs->powersaving_enabled ? "ON" : "OFF");
rows.draw(tmp, false);
#endif
#if defined(HELTEC_T096)
drawT096StatusPanel(*_display, *_node_prefs, secondary_page ? 1 : 0);
#else
// The shared radio-profile layer reports this on every repeater board.
// BoundedTextRows simply omits this extra row on a screen too short to
// hold it, rather than overwriting an existing status line.
if (the_mesh.isDualRadioActive()) rows.draw("DualRadio: ON", false);
#endif
}
}
#ifdef DISPLAY_REDRAW_ON_CHANGE
uint32_t UITask::getFrameSignature() {
uint32_t signature = DisplayFrameSignature::INITIAL;
char tmp[80];
if (!_startup_complete && (uint32_t)(millis() - _started_at) < BOOT_SCREEN_MILLIS) {
signature = DisplayFrameSignature::append(signature, "boot");
return DisplayFrameSignature::append(signature, _version_info);
}
if (_powering_off_at > 0) {
return DisplayFrameSignature::append(signature, "powering-off");
}
#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);
uint8_t status_page_index = 0;
if (showingRadioProfileSystemStatusPage(&status_page_index)) {
signature = DisplayFrameSignature::append(signature, "radio-system-status");
snprintf(tmp, sizeof(tmp), "status-page-%u", status_page_index);
signature = DisplayFrameSignature::append(signature, tmp);
signature = DisplayFrameSignature::append(
signature, _node_prefs->powersaving_enabled ? "powersaving-on" : "powersaving-off");
signature = DisplayFrameSignature::append(
signature, _node_prefs->gps_enabled ? "gps-on" : "gps-off");
signature = DisplayFrameSignature::append(
signature, _board->canControlLoRaFemLna() && _board->isLoRaFemLnaEnabled()
? "fem-on" : "fem-off");
signature = DisplayFrameSignature::append(
signature, _node_prefs->rx_boosted_gain ? "rxb-on" : "rxb-off");
signature = DisplayFrameSignature::append(
signature, _node_prefs->rx_powersaving_enabled ? "rxps-on" : "rxps-off");
signature = DisplayFrameSignature::append(
signature, _node_prefs->cad_enabled ? "cad-on" : "cad-off");
const mesh::RadioProfiles* status_profiles = configuredRadioProfiles();
if (status_profiles != NULL && status_profiles->enabled()) {
formatRadioNoiseFloor(tmp, sizeof(tmp), "N1", 0);
signature = DisplayFrameSignature::append(signature, tmp);
formatRadioNoiseFloor(tmp, sizeof(tmp), "N2", 1);
signature = DisplayFrameSignature::append(signature, tmp);
signature = DisplayFrameSignature::append(signature,
status_profiles->secondary.mode == mesh::RadioProfileMode::RxTx
? "radio2-rxtx" : "radio2-rx");
signature = DisplayFrameSignature::append(signature,
status_profiles->cross == mesh::RadioCrossMode::On ? "cross-on"
: status_profiles->cross == mesh::RadioCrossMode::Off
? "cross-off" : "cross-auto");
}
return signature;
}
const mesh::RadioProfiles* profiles = configuredRadioProfiles();
const bool secondary_page = showingSecondaryRadioProfilePage();
const bool dual_radio = profiles != NULL && profiles->enabled();
float freq = _node_prefs->freq;
float bw = _node_prefs->bw;
uint8_t sf = _node_prefs->sf;
uint8_t cr = _node_prefs->cr;
if (dual_radio) {
const uint8_t profile = secondary_page ? 1 : 0;
const auto& params = profiles->params(profile);
freq = params.freq;
bw = params.bw;
sf = params.sf;
cr = params.cr;
signature = DisplayFrameSignature::append(signature,
mesh::ui::radioProfileDisplayTag(profile,
profile == 1 ? profiles->secondary_temporary : profiles->primary_temporary));
}
snprintf(tmp, sizeof(tmp), "FREQ: %06.3f SF%d", freq, sf);
signature = DisplayFrameSignature::append(signature, tmp);
snprintf(tmp, sizeof(tmp), "BW: %03.2f CR: %d", bw, 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
#ifndef DISPLAY_ACTIVITY_DASHBOARD
#ifdef WITH_MQTT_BRIDGE
if (WiFi.status() != WL_CONNECTED) {
snprintf(tmp, sizeof(tmp), "BAT: %.2fV", _board->getBattMilliVolts() / 1000.0f);
signature = DisplayFrameSignature::append(signature, tmp);
}
#else
snprintf(tmp, sizeof(tmp), "BAT: %.2fV", _board->getBattMilliVolts() / 1000.0f);
signature = DisplayFrameSignature::append(signature, tmp);
#endif
signature = DisplayFrameSignature::append(
signature, _node_prefs->powersaving_enabled ? "powersaving-on" : "powersaving-off");
#if defined(HELTEC_T096)
signature = DisplayFrameSignature::append(
signature, _node_prefs->gps_enabled ? "gps-on" : "gps-off");
signature = DisplayFrameSignature::append(
signature, _board->canControlLoRaFemLna() && _board->isLoRaFemLnaEnabled()
? "fem-on" : "fem-off");
signature = DisplayFrameSignature::append(
signature, _node_prefs->rx_boosted_gain ? "rxb-on" : "rxb-off");
signature = DisplayFrameSignature::append(
signature, _node_prefs->rx_powersaving_enabled ? "rxps-on" : "rxps-off");
signature = DisplayFrameSignature::append(
signature, _node_prefs->cad_enabled ? "cad-on" : "cad-off");
formatRadioNoiseFloor(tmp, sizeof(tmp), dual_radio
? (secondary_page ? "N2" : "N1") : "N", secondary_page ? 1 : 0);
signature = DisplayFrameSignature::append(signature, tmp);
#endif
signature = DisplayFrameSignature::append(
signature, the_mesh.isDualRadioActive() ? "radio2-on" : "radio2-off");
#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 = "REPEATER";
ctx->freq = _node_prefs->freq;
ctx->sf = _node_prefs->sf;
ctx->bw = _node_prefs->bw;
ctx->radio_label = "";
const mesh::RadioProfiles* profiles = configuredRadioProfiles();
if (profiles != NULL && profiles->enabled()) {
const uint8_t profile = showingSecondaryRadioProfilePage() ? 1 : 0;
const auto& params = profiles->params(profile);
ctx->freq = params.freq;
ctx->sf = params.sf;
ctx->bw = params.bw;
ctx->radio_label = mesh::ui::radioProfileDisplayTag(profile,
profile == 1 ? profiles->secondary_temporary : profiles->primary_temporary);
}
ctx->dual_radio = the_mesh.isDualRadioActive();
#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(*_display);
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(*_display), ctx, snap,
_row_signatures);
}
#endif
#ifdef DISPLAY_TOUCH_TOGGLE
void UITask::toggleDisplay(const char* source) {
_display->wake(mesh::ui::DisplayWake::Button);
resetRadioProfileDisplayPage();
#ifdef DISPLAY_TOUCH_DEBUG
mesh::usbConsolePort().printf("Display: %s -> %s\n", source, _display->isOn() ? "on" : "off");
#else
(void)source;
#endif
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false; // wake draws one complete current frame
#endif
#ifdef DISPLAY_ACTIVITY_DASHBOARD
_rows_valid = false;
#endif
_next_refresh = 0; // redraw at once rather than showing the stale frame
}
#endif
void UITask::loop() {
#if defined(HELTEC_V4_R8_TFT) && defined(HELTEC_TFT_RUNTIME_ROTATION) && HELTEC_TFT_RUNTIME_ROTATION == 1
applyDisplayRotation();
#endif
// Observe expiry outside display/Morse/input gates; startup is one-shot.
if (!_startup_complete && (uint32_t)(millis() - _started_at) >= BOOT_SCREEN_MILLIS) {
_startup_complete = true;
}
const bool morse_timing = mesh::morseTimingActive(the_mesh.getProfileRadio());
if (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) {
if (_display->isOn()) resetRadioProfileDisplayPage();
_next_refresh = 0;
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false;
#endif
#ifdef DISPLAY_ACTIVITY_DASHBOARD
_rows_valid = false;
#endif
}
#if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS)
int ev = user_btn.check();
// Multiclick stays enabled on the R8 observer so triple-click can toggle
// WebConfig. Collapse a completed double-click into one display action
// instead of silently consuming both presses.
if (ev == BUTTON_EVENT_CLICK || ev == BUTTON_EVENT_DOUBLE_CLICK) {
// A page change is still user activity. Capture the old state first so
// the first press wakes to R1, while every subsequent press both extends
// the 15-second timer and advances to the next available detail page.
const bool display_was_on = _display->isOn();
_display->wake(mesh::ui::DisplayWake::Button);
if (display_was_on) {
advanceRadioProfileDisplayPage();
} else {
resetRadioProfileDisplayPage();
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false;
#endif
#ifdef DISPLAY_ACTIVITY_DASHBOARD
_rows_valid = false;
#endif
}
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
_display->wake(mesh::ui::DisplayWake::Button);
resetRadioProfileDisplayPage();
mesh::usbConsolePort().printf("Powering Off\r\n");
_powering_off_at = millis() + POWEROFF_DELAY;
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false;
#endif
#ifdef DISPLAY_ACTIVITY_DASHBOARD
_rows_valid = false;
#endif
_next_refresh = 0;
#ifdef WITH_WEBCONFIG
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
// Preserve the fork's persistent WebUI toggle. R8 observer repeaters keep
// multiclick enabled specifically for this action.
WebConfigServer::requestToggleFromButton();
_display->wake(mesh::ui::DisplayWake::Button);
resetRadioProfileDisplayPage();
#endif
}
#endif
#ifdef DISPLAY_TOUCH_TOGGLE
{
unsigned long now = millis();
if (millisReached(now, _next_touch)) {
_next_touch = now + TOUCH_POLL_MILLIS;
if (_powering_off_at == 0 && _touch.checkTap(now)) toggleDisplay("touch");
}
}
#endif
if (!morse_timing && _display->isOn()) {
// Apply a live orientation change before drawing, including the first frame
// after a preference was changed while the panel was blanked.
applyDisplayFlip();
if (millisReached(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
unsigned long refresh_interval = 1000; // check normal status changes every second
#if defined(HELTEC_T096)
// The T096's TFT can show each tenth while the short, 3.2-second noise
// floor collection window is active. Keep every other screen at the
// normal cadence, particularly displays with slow/expensive refreshes.
if (radio_driver.getNoiseFloorCalibrationSecondsRemaining() > 0.0f) {
refresh_interval = 100;
}
#endif
_next_refresh = millis() + refresh_interval;
}
}
if (_powering_off_at > 0) { // power off timer armed
#if defined(LED_PIN) && defined(LED_STATE_ON)
if (mesh::leds::enabled()) digitalWrite(LED_PIN, LED_STATE_ON);
delay(1000);
digitalWrite(LED_PIN, !LED_STATE_ON); // off LED
#endif
if (millisReached(millis(), _powering_off_at)) {
_board->powerOff(); // should not return
}
}
}