Files
MeshCore/examples/simple_sensor/UITask.cpp
T
mikecarper b00d346df7 Unify Heltec V4 OLED, TFT and headless display selection
Detect OLED or the original stock TFT touch assembly in ordinary V4.2/V4.3
images. Add saved live display.type auto/oled/tft/off selection and report
the active selection in board output. Keep sensors on primary I2C, preserve
shared power, and isolate TFT FSPI from radio HSPI through sleep and changes.

Consolidate future picker/build choices only with application capability
proof. Retain exact legacy OTA identities and separate R8 hardware images.
Document controls and physical qualification limits.

Validation: five serialized Full builds with capability/size/RAM checks;
focused host tests, existing R8/T-Deck/OLED regressions, build-profile suite,
and picker/provider tests. V4.3 OLED live changes and off/auto reboot
persistence passed on Mercerwood. Physical TFT qualification remains pending.
2026-10-26 15:19:17 -07:00

305 lines
12 KiB
C++

#include "UITask.h"
#include <helpers/MorseLoopPolicy.h>
#include "target.h"
#include <Arduino.h>
#include <helpers/CommonCLI.h>
#include <helpers/ui/RadioProfileDisplayPage.h>
#include <helpers/ui/RadioProfileSystemStatus.h>
#include <RadioProfiles.h>
#include "SensorMesh.h"
namespace {
const mesh::RadioProfiles* configuredRadioProfiles() {
const auto* radio = activeSensorMesh().getProfileRadio();
return radio ? radio->profiles() : NULL;
}
mesh::ui::RadioProfileSystemStatus radioProfileSystemStatus(
const NodePrefs& prefs) {
mesh::ui::RadioProfileSystemStatus status;
const auto* profiles = configuredRadioProfiles();
status.public_key = activeSensorMesh().getSelfId().pub_key;
status.powersaving_enabled = prefs.powersaving_enabled != 0;
#if ENV_INCLUDE_GPS == 1
status.gps_enabled = prefs.gps_enabled != 0;
#endif
status.fem_enabled = board.canControlLoRaFemLna()
&& board.isLoRaFemLnaEnabled();
status.rx_boosted_gain = prefs.rx_boosted_gain != 0;
status.rx_powersaving_enabled = prefs.rx_powersaving_enabled != 0;
status.cad_enabled = prefs.cad_enabled != 0;
status.dual_radio_enabled = profiles != NULL && profiles->enabled();
if (status.dual_radio_enabled) {
status.secondary_temporary = profiles->secondary_temporary;
status.secondary_mode = profiles->secondary.mode;
status.cross = profiles->cross;
}
status.noise_floor_1 = radio_driver.getNoiseFloorDbm(0);
status.noise_floor_1_seconds =
radio_driver.getNoiseFloorCalibrationSecondsRemaining(0);
if (status.dual_radio_enabled) {
status.noise_floor_2 = radio_driver.getNoiseFloorDbm(1);
status.noise_floor_2_seconds =
radio_driver.getNoiseFloorCalibrationSecondsRemaining(1);
}
return status;
}
uint8_t radioProfileStatusPageCount(DisplayDriver& display,
bool dual_radio_enabled) {
display.setTextSize(1);
return mesh::ui::radioProfileSystemStatusPageCount(display,
dual_radio_enabled);
}
} // namespace
#ifndef USER_BTN_PRESSED
#define USER_BTN_PRESSED LOW
#endif
#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,
};
#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;
_last_refresh = uint32_t(millis()) - 1000u;
}
#endif
void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) {
_prevBtnState = HIGH;
_node_prefs = node_prefs;
resetRadioProfileDisplayPage();
#if defined(HELTEC_V4_R8_TFT) && defined(HELTEC_TFT_RUNTIME_ROTATION) && HELTEC_TFT_RUNTIME_ROTATION == 1
applyDisplayRotation();
#endif
_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected());
#if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) \
&& defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \
&& MOMENTARY_BUTTON_WAKE_FROM_SLEEP
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);
// Make the first read/frame immediately due, including begin() after wrap.
const uint32_t now = millis();
_last_read = now - 200u;
_last_refresh = now - 1000u;
_startup_complete = now >= BOOT_SCREEN_MILLIS;
}
void UITask::resetRadioProfileDisplayPage() {
_radio_profile_page_started_at = millis();
_dual_radio_enabled_seen = activeSensorMesh().isDualRadioActive();
}
bool UITask::showingSecondaryRadioProfilePage() {
const bool dual_radio_enabled = activeSensorMesh().isDualRadioActive();
const uint32_t now = millis();
if (dual_radio_enabled != _dual_radio_enabled_seen) {
_dual_radio_enabled_seen = dual_radio_enabled;
_radio_profile_page_started_at = now;
}
const uint8_t status_pages = radioProfileStatusPageCount(*_display,
dual_radio_enabled);
return mesh::ui::showSecondaryRadioProfilePage(dual_radio_enabled,
status_pages,
now - _radio_profile_page_started_at);
}
void UITask::renderCurrScreen() {
char tmp[80];
if (!_startup_complete && 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 = "< Sensor >";
uint16_t typeWidth = _display->getTextWidth(node_type);
_display->setCursor((_display->width() - typeWidth) / 2, 48);
_display->print(node_type);
} else { // home screen
const bool dual_radio_enabled = activeSensorMesh().isDualRadioActive();
const uint8_t status_pages = radioProfileStatusPageCount(
*_display, dual_radio_enabled);
uint8_t status_page_index = 0;
if (mesh::ui::showRadioProfileSystemStatusPage(dual_radio_enabled,
status_pages, millis() - _radio_profile_page_started_at,
&status_page_index)) {
mesh::ui::drawRadioProfileSystemStatusPage(*_display,
radioProfileSystemStatus(*_node_prefs), status_page_index);
return;
}
// node name
_display->setCursor(0, 0);
_display->setTextSize(1);
_display->setColor(UIColor::primary_txt);
_display->print(_node_prefs->node_name);
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 (profiles != NULL) {
const uint8_t profile = dual_radio && secondary_page ? 1 : 0;
const auto& params = profiles->params(profile);
freq = params.freq;
bw = params.bw;
sf = params.sf;
cr = params.cr;
if (dual_radio) profile_tag = mesh::ui::radioProfileDisplayTag(profile,
profile == 1 ? profiles->secondary_temporary : profiles->primary_temporary);
}
// Compact R1/R2 rows preserve every RF field on 128px displays.
_display->setCursor(0, 20);
if (dual_radio) {
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);
}
_display->print(tmp);
_display->setCursor(0, 30);
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);
}
_display->print(tmp);
}
}
void UITask::loop() {
#if defined(HELTEC_V4_DISPLAY_UNIFIED) && HELTEC_V4_DISPLAY_UNIFIED
static uint32_t display_generation = 0;
if (display_generation != display.generation()) {
display_generation = display.generation();
_last_refresh = millis() - 1000u;
#ifdef DISPLAY_REDRAW_ON_CHANGE
_frame_valid = false;
#endif
#ifdef DISPLAY_ACTIVITY_DASHBOARD
_rows_valid = false;
#endif
}
#endif
#if defined(HELTEC_V4_R8_TFT) && defined(HELTEC_TFT_RUNTIME_ROTATION) && HELTEC_TFT_RUNTIME_ROTATION == 1
applyDisplayRotation();
#endif
// This UI's splash is relative to boot, not to begin(). Retire it once.
if (!_startup_complete && millis() >= BOOT_SCREEN_MILLIS) {
_startup_complete = true;
}
const bool morse_timing = mesh::morseTimingActive(activeSensorMesh().getProfileRadio());
#ifdef MESHCORE_HAS_COLOR_DISPLAY
const bool screen_was_on = _display->isOn();
#endif
if (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) {
#ifdef MESHCORE_HAS_COLOR_DISPLAY
if (screen_was_on != _display->isOn())
#endif
resetRadioProfileDisplayPage();
_last_refresh = millis() - 1000u;
}
#if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) \
&& defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \
&& MOMENTARY_BUTTON_WAKE_FROM_SLEEP
int ev = user_btn.check();
if (ev != BUTTON_EVENT_NONE) {
if (!_display->isOn()) {
_display->wake(mesh::ui::DisplayWake::Button);
resetRadioProfileDisplayPage();
_last_refresh = millis() - 1000u;
}
_display->wake(mesh::ui::DisplayWake::Button);
}
#elif defined(PIN_USER_BTN)
if (static_cast<uint32_t>(millis() - _last_read) >= 200u) {
int btnState = digitalRead(PIN_USER_BTN);
if (btnState != _prevBtnState) {
if (btnState == USER_BTN_PRESSED) { // pressed?
if (_display->isOn()) {
// TODO: any action ?
} else {
_display->wake(mesh::ui::DisplayWake::Button);
resetRadioProfileDisplayPage();
}
_display->wake(mesh::ui::DisplayWake::Button); // extend auto-off timer
}
_prevBtnState = btnState;
}
_last_read = millis(); // 5 reads per second, timed from completed I/O
}
#endif
if (!morse_timing && _display->isOn()) {
if (static_cast<uint32_t>(millis() - _last_refresh) >= 1000u) {
_display->startFrame();
renderCurrScreen();
_display->endFrame();
_last_refresh = millis(); // refresh every second, after the frame ends
}
#if MOMENTARY_BUTTON_WAKE_HOLD_MS > 0 && defined(PIN_USER_BTN)
if (user_btn.isWakeHoldActive()) _display->wake(mesh::ui::DisplayWake::Button);
#endif
}
}