Persist set freq and set radio atomically, then apply through the existing safe radio scheduler without rebooting. Retain the old tuple until the delay and reply handoff finish, including queued room/sensor replies and TX-complete callbacks. End temporary sessions when accepting a permanent change. Display applied RF settings even when radio2 is off. Share radio validation across immediate and scheduled setters to preserve STM32 Mini flash capacity. Update command documentation, regression coverage, and CI. Validated production timing, persistence, display, CLI, fleet/reply, RX, sleep and Morse regressions. W12 repeater, room-server and sensor builds pass. Canonical STM32 Mini Repeater passes flash and RAM admission with 448 bytes of flash remaining. Physical W12 RF testing was not performed.
292 lines
11 KiB
C++
292 lines
11 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_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
|
|
|
|
}
|
|
}
|