feat(ui): page radio and system status
This commit is contained in:
1 parent
527bc98da2
commit
a95f7b3d16
28 files changed
+2075
-73
No files matched your search
@@ -12,6 +12,9 @@
|
||||
#include <helpers/ui/SmallMessageText.h>
|
||||
#endif
|
||||
#include <helpers/ui/CompanionTransportSelectorLayout.h>
|
||||
#include <helpers/ui/RadioProfileDisplayPage.h>
|
||||
#include <helpers/ui/RadioProfileSystemStatus.h>
|
||||
#include <RadioProfiles.h>
|
||||
#include "../MyMesh.h"
|
||||
#include "../CompanionWiFi.h"
|
||||
#include "target.h"
|
||||
@@ -66,6 +69,38 @@ static uint64_t companionMessageElapsedMillis(uint64_t heard_millis) {
|
||||
#endif
|
||||
}
|
||||
|
||||
static mesh::ui::RadioProfileSystemStatus radioProfileSystemStatus(
|
||||
const CompanionNodePrefs& prefs) {
|
||||
mesh::ui::RadioProfileSystemStatus status;
|
||||
const auto* radio = the_mesh.getProfileRadio();
|
||||
const mesh::RadioProfiles* profiles = radio ? radio->profiles() : NULL;
|
||||
status.public_key = the_mesh.self_id.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;
|
||||
}
|
||||
|
||||
#ifndef UI_RECENT_LIST_SIZE
|
||||
#define UI_RECENT_LIST_SIZE 4
|
||||
#endif
|
||||
@@ -421,6 +456,8 @@ class HomeScreen : public UIScreen {
|
||||
#endif
|
||||
uint32_t _uptime_last_millis;
|
||||
uint64_t _uptime_millis;
|
||||
uint32_t _radio_profile_page_started_at = 0;
|
||||
bool _dual_radio_enabled_seen = false;
|
||||
AdvertPath recent[UI_RECENT_LIST_SIZE];
|
||||
#if !(defined(UI_NO_DISCOVER_SCREEN) && (UI_NO_DISCOVER_SCREEN + 0 != 0))
|
||||
DiscoveredNode discovered[UI_RECENT_LIST_SIZE];
|
||||
@@ -558,6 +595,35 @@ class HomeScreen : public UIScreen {
|
||||
}
|
||||
}
|
||||
|
||||
void resetRadioProfileDisplayPage() {
|
||||
_radio_profile_page_started_at = millis();
|
||||
_dual_radio_enabled_seen = the_mesh.isDualRadioActive();
|
||||
}
|
||||
|
||||
bool showingSecondaryRadioProfilePage(DisplayDriver& display,
|
||||
int status_top) {
|
||||
const bool dual_radio_enabled = the_mesh.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;
|
||||
}
|
||||
display.setTextSize(1);
|
||||
const uint8_t status_pages = mesh::ui::radioProfileSystemStatusPageCount(
|
||||
display, dual_radio_enabled, status_top);
|
||||
return mesh::ui::showSecondaryRadioProfilePage(dual_radio_enabled,
|
||||
status_pages,
|
||||
now - _radio_profile_page_started_at);
|
||||
}
|
||||
|
||||
int radioProfileRefreshMillis() const {
|
||||
const uint32_t elapsed = millis() - _radio_profile_page_started_at;
|
||||
const uint32_t remaining = mesh::ui::RADIO_PROFILE_DISPLAY_PAGE_MILLIS
|
||||
- elapsed % mesh::ui::RADIO_PROFILE_DISPLAY_PAGE_MILLIS;
|
||||
return remaining < UI_RADIO_REFRESH_MILLIS ? (int)remaining
|
||||
: UI_RADIO_REFRESH_MILLIS;
|
||||
}
|
||||
|
||||
public:
|
||||
HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, CompanionNodePrefs* node_prefs)
|
||||
: _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0),
|
||||
@@ -893,13 +959,50 @@ public:
|
||||
} else if (_page == HomePage::RADIO) {
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.setTextSize(1);
|
||||
const auto* radio = the_mesh.getProfileRadio();
|
||||
const mesh::RadioProfiles* profiles = radio ? radio->profiles() : NULL;
|
||||
const bool dual_radio = profiles != NULL && profiles->enabled();
|
||||
const uint8_t status_pages = mesh::ui::radioProfileSystemStatusPageCount(
|
||||
display, dual_radio, body_top);
|
||||
uint8_t status_page_index = 0;
|
||||
if (mesh::ui::showRadioProfileSystemStatusPage(dual_radio, status_pages,
|
||||
millis() - _radio_profile_page_started_at, &status_page_index)) {
|
||||
mesh::ui::drawRadioProfileSystemStatusPage(display,
|
||||
radioProfileSystemStatus(*_node_prefs), status_page_index, body_top);
|
||||
return radioProfileRefreshMillis();
|
||||
}
|
||||
const bool secondary_page = showingSecondaryRadioProfilePage(display,
|
||||
body_top);
|
||||
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);
|
||||
}
|
||||
// freq / sf
|
||||
display.setCursor(0, body_top);
|
||||
sprintf(tmp, "FQ: %06.3f SF: %d", _node_prefs->freq, _node_prefs->sf);
|
||||
if (dual_radio) {
|
||||
snprintf(tmp, sizeof(tmp), "%s F:%06.3f S:%d", profile_tag, freq, sf);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "FQ: %06.3f SF: %d", freq, sf);
|
||||
}
|
||||
display.print(tmp);
|
||||
|
||||
display.setCursor(0, body_top + row_height);
|
||||
sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr);
|
||||
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);
|
||||
|
||||
// tx power, noise floor
|
||||
@@ -907,11 +1010,23 @@ public:
|
||||
sprintf(tmp, "TX: %ddBm", _node_prefs->tx_power_dbm);
|
||||
display.print(tmp);
|
||||
display.setCursor(0, body_top + 3 * row_height);
|
||||
float noise_floor = radio_driver.getNoiseFloorDbm();
|
||||
// The RF rows above can be showing R1 or R2. Read the matching
|
||||
// estimator too; otherwise an R2-labelled page silently showed R1's
|
||||
// noise floor.
|
||||
const uint8_t noise_profile = dual_radio && secondary_page ? 1 : 0;
|
||||
const char* noise_label = dual_radio
|
||||
? (noise_profile == 0 ? "N1" : "N2") : "Noise floor";
|
||||
float noise_floor = radio_driver.getNoiseFloorDbm(noise_profile);
|
||||
if (noise_floor == 0.0f) {
|
||||
strcpy(tmp, "Noise floor: measuring");
|
||||
const float seconds = radio_driver
|
||||
.getNoiseFloorCalibrationSecondsRemaining(noise_profile);
|
||||
if (seconds > 0.0f) {
|
||||
snprintf(tmp, sizeof(tmp), "%s: %.1fs", noise_label, seconds);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "%s: WAIT", noise_label);
|
||||
}
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "Noise floor: %.1f", noise_floor);
|
||||
snprintf(tmp, sizeof(tmp), "%s: %.1f", noise_label, noise_floor);
|
||||
}
|
||||
display.print(tmp);
|
||||
#ifdef COMPANION_EXCLUSIVE_WIFI_BLE
|
||||
@@ -1152,7 +1267,7 @@ public:
|
||||
}
|
||||
#endif
|
||||
}
|
||||
if (_page == HomePage::RADIO) return UI_RADIO_REFRESH_MILLIS;
|
||||
if (_page == HomePage::RADIO) return radioProfileRefreshMillis();
|
||||
#if UI_MESSAGES_HOME_PAGE == 1
|
||||
if (_page == HomePage::MESSAGES) return 1000;
|
||||
#endif
|
||||
@@ -1167,6 +1282,7 @@ public:
|
||||
const uint8_t key = static_cast<uint8_t>(c);
|
||||
if (key == KEY_LEFT || key == KEY_PREV) {
|
||||
_page = (_page + HomePage::Count - 1) % HomePage::Count;
|
||||
if (_page == HomePage::RADIO) resetRadioProfileDisplayPage();
|
||||
return true;
|
||||
}
|
||||
#if COMPANION_FEATURE_READER
|
||||
@@ -1177,6 +1293,7 @@ public:
|
||||
#endif
|
||||
if (key == KEY_NEXT || key == KEY_RIGHT) {
|
||||
_page = (_page + 1) % HomePage::Count;
|
||||
if (_page == HomePage::RADIO) resetRadioProfileDisplayPage();
|
||||
if (_page == HomePage::RECENT) {
|
||||
_task->showAlert("Recent adverts", 800);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
#include <Arduino.h>
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
#include <helpers/ui/BluetoothPairingUiPolicy.h>
|
||||
#include <helpers/ui/RadioProfileDisplayPage.h>
|
||||
#include <helpers/ui/RadioProfileSystemStatus.h>
|
||||
#include <RadioProfiles.h>
|
||||
#include "../MyMesh.h"
|
||||
|
||||
#define AUTO_OFF_MILLIS 15000 // 15 seconds
|
||||
@@ -30,6 +33,49 @@
|
||||
#define USER_BTN_PRESSED LOW
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
mesh::ui::RadioProfileSystemStatus radioProfileSystemStatus(
|
||||
const CompanionNodePrefs& prefs, const mesh::MainBoard& board) {
|
||||
mesh::ui::RadioProfileSystemStatus status;
|
||||
const auto* radio = the_mesh.getProfileRadio();
|
||||
const mesh::RadioProfiles* profiles = radio ? radio->profiles() : NULL;
|
||||
status.public_key = the_mesh.self_id.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
|
||||
|
||||
// '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,
|
||||
@@ -56,6 +102,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode
|
||||
|
||||
clearMsgPreview();
|
||||
_node_prefs = node_prefs;
|
||||
resetRadioProfileDisplayPage();
|
||||
if (_display != NULL) {
|
||||
_display->servicePower(_board->isExternalPowered() || _board->isUsbHostConnected(), hasConnection());
|
||||
}
|
||||
@@ -112,6 +159,39 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode
|
||||
ui_started_at = millis();
|
||||
}
|
||||
|
||||
void UITask::resetRadioProfileDisplayPage() {
|
||||
_radio_profile_page_started_at = millis();
|
||||
_dual_radio_enabled_seen = the_mesh.isDualRadioActive();
|
||||
_radio_profile_display_page_seen = 0;
|
||||
}
|
||||
|
||||
bool UITask::showingSecondaryRadioProfilePage() {
|
||||
const bool dual_radio_enabled = the_mesh.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::serviceRadioProfileDisplayPage() {
|
||||
const bool dual_radio_enabled = the_mesh.isDualRadioActive();
|
||||
const uint8_t status_pages = radioProfileStatusPageCount(*_display,
|
||||
dual_radio_enabled);
|
||||
const uint8_t page = mesh::ui::radioProfileDisplayPageIndex(
|
||||
dual_radio_enabled, status_pages,
|
||||
millis() - _radio_profile_page_started_at);
|
||||
if (page != _radio_profile_display_page_seen) {
|
||||
_radio_profile_display_page_seen = page;
|
||||
_need_refresh = true;
|
||||
}
|
||||
}
|
||||
|
||||
bool UITask::shouldPlayMessageTone() const {
|
||||
#ifdef BLE_PIN_CODE
|
||||
return !hasBluetoothConnection();
|
||||
@@ -272,6 +352,18 @@ void UITask::renderCurrScreen() {
|
||||
_display->setCursor((_display->width() - textWidth) / 2, 22);
|
||||
_display->print(_version_info);
|
||||
} else { // home screen
|
||||
const bool dual_radio_enabled = the_mesh.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, *_board), status_page_index);
|
||||
_need_refresh = false;
|
||||
return;
|
||||
}
|
||||
// node name
|
||||
_display->setCursor(0, 0);
|
||||
_display->setTextSize(1);
|
||||
@@ -281,15 +373,42 @@ void UITask::renderCurrScreen() {
|
||||
// battery voltage
|
||||
renderBatteryIndicator(_board->getBattMilliVolts());
|
||||
|
||||
// freq / sf
|
||||
const auto* radio = the_mesh.getProfileRadio();
|
||||
const mesh::RadioProfiles* profiles = radio ? radio->profiles() : NULL;
|
||||
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);
|
||||
}
|
||||
|
||||
// Compact R1/R2 rows preserve every RF field on 128px displays.
|
||||
_display->setCursor(0, 20);
|
||||
_display->setColor(UIColor::secondary_txt);
|
||||
sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf);
|
||||
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);
|
||||
|
||||
// bw / cr
|
||||
_display->setCursor(0, 30);
|
||||
sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr);
|
||||
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);
|
||||
|
||||
// BT pin
|
||||
@@ -538,6 +657,10 @@ void UITask::loop() {
|
||||
#endif
|
||||
|
||||
if (_display != NULL && _display->isOn()) {
|
||||
if (!isPairingScreenActive() && !_alert[0] && !(_origin[0] && _msg[0])
|
||||
&& (millis() - ui_started_at) >= BOOT_SCREEN_MILLIS) {
|
||||
serviceRadioProfileDisplayPage();
|
||||
}
|
||||
static bool _firstBoot = true;
|
||||
if(_firstBoot && (millis() - ui_started_at) >= BOOT_SCREEN_MILLIS) {
|
||||
_need_refresh = true;
|
||||
@@ -560,6 +683,7 @@ void UITask::handleButtonAnyPress() {
|
||||
if (_display != NULL) {
|
||||
_displayWasOn = _display->isOn(); // Track display state before any action
|
||||
_display->wake(mesh::ui::DisplayWake::Button);
|
||||
if (!_displayWasOn) resetRadioProfileDisplayPage();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,9 @@ class UITask : public AbstractUITask {
|
||||
bool _displayWasOn = false; // Track display state before button press
|
||||
unsigned long _pairing_screen_until;
|
||||
unsigned long ui_started_at;
|
||||
uint32_t _radio_profile_page_started_at = 0;
|
||||
bool _dual_radio_enabled_seen = false;
|
||||
uint8_t _radio_profile_display_page_seen = 0;
|
||||
|
||||
// Button handlers
|
||||
#ifdef PIN_USER_BTN
|
||||
@@ -64,6 +67,9 @@ class UITask : public AbstractUITask {
|
||||
void handleButtonLongPress();
|
||||
bool shouldPlayMessageTone() const;
|
||||
bool isPairingScreenActive() const;
|
||||
void resetRadioProfileDisplayPage();
|
||||
bool showingSecondaryRadioProfilePage();
|
||||
void serviceRadioProfileDisplayPage();
|
||||
void showPairingPin();
|
||||
void finishPairingScreen(bool timed_out);
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
#endif
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
#include <helpers/ui/BluetoothPairingUiPolicy.h>
|
||||
#include <helpers/ui/RadioProfileDisplayPage.h>
|
||||
#include <helpers/ui/RadioProfileSystemStatus.h>
|
||||
#include "../MyMesh.h"
|
||||
#include <RadioProfiles.h>
|
||||
#include "target.h"
|
||||
#include "u8g2_icons.h"
|
||||
|
||||
@@ -28,6 +31,42 @@
|
||||
|
||||
#define LONG_PRESS_MILLIS 1200
|
||||
|
||||
namespace {
|
||||
|
||||
mesh::ui::RadioProfileSystemStatus radioProfileSystemStatus(
|
||||
const CompanionNodePrefs& prefs) {
|
||||
mesh::ui::RadioProfileSystemStatus status;
|
||||
const auto* radio = the_mesh.getProfileRadio();
|
||||
const mesh::RadioProfiles* profiles = radio ? radio->profiles() : NULL;
|
||||
status.public_key = the_mesh.self_id.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;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
#ifndef UI_RECENT_LIST_SIZE
|
||||
#define UI_RECENT_LIST_SIZE 4
|
||||
#endif
|
||||
@@ -124,6 +163,8 @@ class HomeScreen : public UIScreen {
|
||||
bool sensors_scroll = false;
|
||||
int sensors_scroll_offset = 0;
|
||||
int next_sensors_refresh = 0;
|
||||
uint32_t _radio_profile_page_started_at = 0;
|
||||
bool _dual_radio_enabled_seen = false;
|
||||
|
||||
void refresh_sensors() {
|
||||
if (millis() > next_sensors_refresh) {
|
||||
@@ -146,10 +187,39 @@ class HomeScreen : public UIScreen {
|
||||
}
|
||||
}
|
||||
|
||||
void resetRadioProfileDisplayPage() {
|
||||
_radio_profile_page_started_at = millis();
|
||||
_dual_radio_enabled_seen = the_mesh.isDualRadioActive();
|
||||
}
|
||||
|
||||
bool showingSecondaryRadioProfilePage(DisplayDriver& display) {
|
||||
const bool dual_radio_enabled = the_mesh.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;
|
||||
}
|
||||
display.setTextSize(1);
|
||||
const uint8_t status_pages = mesh::ui::radioProfileSystemStatusPageCount(
|
||||
display, dual_radio_enabled, 8);
|
||||
return mesh::ui::showSecondaryRadioProfilePage(dual_radio_enabled,
|
||||
status_pages,
|
||||
now - _radio_profile_page_started_at);
|
||||
}
|
||||
|
||||
int radioProfileRefreshMillis() const {
|
||||
const uint32_t elapsed = millis() - _radio_profile_page_started_at;
|
||||
const uint32_t remaining = mesh::ui::RADIO_PROFILE_DISPLAY_PAGE_MILLIS
|
||||
- elapsed % mesh::ui::RADIO_PROFILE_DISPLAY_PAGE_MILLIS;
|
||||
return remaining < 5000 ? static_cast<int>(remaining) : 5000;
|
||||
}
|
||||
|
||||
public:
|
||||
HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, CompanionNodePrefs* node_prefs)
|
||||
: _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0),
|
||||
_shutdown_init(false), sensors_lpp(200) { }
|
||||
_shutdown_init(false), sensors_lpp(200) {
|
||||
resetRadioProfileDisplayPage();
|
||||
}
|
||||
|
||||
void showFirstPage() { _page = HomePage::FIRST; }
|
||||
|
||||
@@ -230,23 +300,72 @@ public:
|
||||
} else if (_page == HomePage::RADIO) {
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.setTextSize(1);
|
||||
const auto* radio = the_mesh.getProfileRadio();
|
||||
const mesh::RadioProfiles* profiles = radio ? radio->profiles() : NULL;
|
||||
const bool dual_radio = profiles != NULL && profiles->enabled();
|
||||
const uint8_t status_pages = mesh::ui::radioProfileSystemStatusPageCount(
|
||||
display, dual_radio, 8);
|
||||
uint8_t status_page_index = 0;
|
||||
if (mesh::ui::showRadioProfileSystemStatusPage(dual_radio, status_pages,
|
||||
millis() - _radio_profile_page_started_at, &status_page_index)) {
|
||||
mesh::ui::drawRadioProfileSystemStatusPage(display,
|
||||
radioProfileSystemStatus(*_node_prefs), status_page_index, 8);
|
||||
return radioProfileRefreshMillis();
|
||||
}
|
||||
const bool secondary_page = showingSecondaryRadioProfilePage(display);
|
||||
const uint8_t profile = dual_radio && secondary_page ? 1 : 0;
|
||||
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 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);
|
||||
}
|
||||
// frequency and spreading factor
|
||||
display.setCursor(0, 8);
|
||||
sprintf(tmp, "FQ %06.3f", _node_prefs->freq);
|
||||
if (dual_radio) {
|
||||
snprintf(tmp, sizeof(tmp), "%s F:%06.3f", profile_tag, freq);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "FQ %06.3f", freq);
|
||||
}
|
||||
display.print(tmp);
|
||||
sprintf(tmp, "SF%d", _node_prefs->sf);
|
||||
snprintf(tmp, sizeof(tmp), "S:%d", sf);
|
||||
display.drawTextRightAlign(display.width(), 8, tmp);
|
||||
// bandwidth and coding rate
|
||||
display.setCursor(0, 17);
|
||||
sprintf(tmp, "BW %03.2f", _node_prefs->bw);
|
||||
if (dual_radio) {
|
||||
snprintf(tmp, sizeof(tmp), "%s B:%03.2f", profile_tag, bw);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "BW %03.2f", bw);
|
||||
}
|
||||
display.print(tmp);
|
||||
sprintf(tmp, "CR%d", _node_prefs->cr);
|
||||
snprintf(tmp, sizeof(tmp), "C:%d", cr);
|
||||
display.drawTextRightAlign(display.width(), 17, tmp);
|
||||
// tx power and noise floor
|
||||
display.setCursor(0, 26);
|
||||
sprintf(tmp, "NF %ddB", radio_driver.getNoiseFloor());
|
||||
const char* noise_label = dual_radio ? (profile == 0 ? "N1" : "N2") : "NF";
|
||||
const float noise_floor = radio_driver.getNoiseFloorDbm(profile);
|
||||
if (noise_floor == 0.0f) {
|
||||
const float seconds = radio_driver
|
||||
.getNoiseFloorCalibrationSecondsRemaining(profile);
|
||||
if (seconds > 0.0f) {
|
||||
snprintf(tmp, sizeof(tmp), "%s:%.1fs", noise_label, seconds);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "%s:WAIT", noise_label);
|
||||
}
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "%s:%.1f", noise_label, noise_floor);
|
||||
}
|
||||
display.print(tmp);
|
||||
sprintf(tmp, "TX%d", _node_prefs->tx_power_dbm);
|
||||
snprintf(tmp, sizeof(tmp), "TX:%d", _node_prefs->tx_power_dbm);
|
||||
display.drawTextRightAlign(display.width(), 26, tmp);
|
||||
|
||||
} else if (_page == HomePage::BLUETOOTH) {
|
||||
@@ -387,16 +506,20 @@ public:
|
||||
// display.drawTextCentered(display.width() / 2, 40 - 11, "hibernate:" PRESS_LABEL);
|
||||
}
|
||||
}
|
||||
return 5000; // next render after 5000 ms
|
||||
// Keep the compact radio page on the R1/R2 boundary; other tiny pages
|
||||
// retain their existing low-refresh cadence.
|
||||
return _page == HomePage::RADIO ? radioProfileRefreshMillis() : 5000;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
if (c == KEY_LEFT || c == KEY_PREV) {
|
||||
_page = (_page + HomePage::Count - 1) % HomePage::Count;
|
||||
if (_page == HomePage::RADIO) resetRadioProfileDisplayPage();
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_NEXT || c == KEY_RIGHT) {
|
||||
_page = (_page + 1) % HomePage::Count;
|
||||
if (_page == HomePage::RADIO) resetRadioProfileDisplayPage();
|
||||
if (_page == HomePage::RECENT) {
|
||||
_task->showAlert("Recent adverts", 800);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,261 @@
|
||||
#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>
|
||||
@@ -77,6 +332,7 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi
|
||||
_prevBtnState = HIGH;
|
||||
_started_at = millis();
|
||||
_node_prefs = node_prefs;
|
||||
resetRadioProfileDisplayPage();
|
||||
#ifdef DISPLAY_ACTIVITY_DASHBOARD
|
||||
ObserverDashboard::applyDarkPalette(); // retunes UIColor for this target only
|
||||
#endif
|
||||
@@ -106,6 +362,56 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi
|
||||
free(version);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -192,23 +498,85 @@ void UITask::renderCurrScreen() {
|
||||
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.
|
||||
mesh::ui::BoundedTextRows rows(*_display,
|
||||
{0, 0, _display->width(), _display->height()});
|
||||
#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
|
||||
|
||||
// freq / sf
|
||||
sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf);
|
||||
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);
|
||||
|
||||
// bw / cr
|
||||
sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr);
|
||||
// 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
|
||||
@@ -226,8 +594,38 @@ void UITask::renderCurrScreen() {
|
||||
}
|
||||
|
||||
// Keep power-saving state visible even when the MQTT IP replaces battery.
|
||||
snprintf(tmp, sizeof(tmp), "PowerSaving: %s", _node_prefs->powersaving_enabled ? "ON" : "off");
|
||||
#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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,9 +658,61 @@ uint32_t UITask::getFrameSignature() {
|
||||
|
||||
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);
|
||||
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", _node_prefs->bw, _node_prefs->cr);
|
||||
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)
|
||||
@@ -287,6 +737,24 @@ uint32_t UITask::getFrameSignature() {
|
||||
#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;
|
||||
@@ -303,6 +771,18 @@ bool UITask::buildDashboardContext(ObserverDashboard::Context* ctx) {
|
||||
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
|
||||
@@ -347,6 +827,7 @@ void UITask::updateActivityRows() {
|
||||
#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
|
||||
@@ -364,6 +845,7 @@ void UITask::toggleDisplay(const char* source) {
|
||||
|
||||
void UITask::loop() {
|
||||
if (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) {
|
||||
if (_display->isOn()) resetRadioProfileDisplayPage();
|
||||
_next_refresh = 0;
|
||||
#ifdef DISPLAY_REDRAW_ON_CHANGE
|
||||
_frame_valid = false;
|
||||
@@ -378,13 +860,15 @@ void UITask::loop() {
|
||||
// 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) {
|
||||
#ifdef DISPLAY_TOUCH_TOGGLE
|
||||
toggleDisplay("button"); // same action as tapping the panel
|
||||
#else
|
||||
if (_display->isOn()) {
|
||||
// TODO: any action ?
|
||||
// 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 {
|
||||
_display->wake(mesh::ui::DisplayWake::Button);
|
||||
resetRadioProfileDisplayPage();
|
||||
#ifdef DISPLAY_REDRAW_ON_CHANGE
|
||||
_frame_valid = false;
|
||||
#endif
|
||||
@@ -392,10 +876,9 @@ void UITask::loop() {
|
||||
_rows_valid = false;
|
||||
#endif
|
||||
}
|
||||
_display->wake(mesh::ui::DisplayWake::Button);
|
||||
#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
|
||||
@@ -411,6 +894,7 @@ void UITask::loop() {
|
||||
// multiclick enabled specifically for this action.
|
||||
WebConfigServer::requestToggleFromButton();
|
||||
_display->wake(mesh::ui::DisplayWake::Button);
|
||||
resetRadioProfileDisplayPage();
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
@@ -451,7 +935,16 @@ void UITask::loop() {
|
||||
}
|
||||
#endif
|
||||
|
||||
_next_refresh = millis() + 1000; // check for visible changes every second
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,15 @@ class UITask {
|
||||
char _version_info[32];
|
||||
unsigned long _powering_off_at = 0;
|
||||
unsigned long _started_at = 0;
|
||||
uint8_t _radio_profile_display_page = 0;
|
||||
bool _dual_radio_enabled_seen = false;
|
||||
|
||||
void resetRadioProfileDisplayPage();
|
||||
void advanceRadioProfileDisplayPage();
|
||||
uint8_t currentRadioProfileDisplayPage();
|
||||
bool showingSecondaryRadioProfilePage();
|
||||
bool showingRadioProfileSystemStatusPage(uint8_t* status_page_index = nullptr);
|
||||
uint8_t radioProfileSystemStatusPageCount() const;
|
||||
|
||||
#ifdef DISPLAY_REDRAW_ON_CHANGE
|
||||
uint32_t _last_frame_signature = 0;
|
||||
|
||||
@@ -4,6 +4,59 @@
|
||||
#include <helpers/UsbLogging.h>
|
||||
#include <helpers/CommonCLI.h>
|
||||
#include <helpers/ui/WiFiSetupQrDisplay.h>
|
||||
#include <helpers/ui/RadioProfileDisplayPage.h>
|
||||
#include <helpers/ui/RadioProfileSystemStatus.h>
|
||||
#include <RadioProfiles.h>
|
||||
#include "MyMesh.h"
|
||||
|
||||
extern MyMesh the_mesh;
|
||||
|
||||
namespace {
|
||||
|
||||
const mesh::RadioProfiles* configuredRadioProfiles() {
|
||||
const auto* radio = the_mesh.getProfileRadio();
|
||||
return radio ? radio->profiles() : NULL;
|
||||
}
|
||||
|
||||
mesh::ui::RadioProfileSystemStatus radioProfileSystemStatus(
|
||||
const NodePrefs& prefs) {
|
||||
mesh::ui::RadioProfileSystemStatus status;
|
||||
const auto* profiles = configuredRadioProfiles();
|
||||
status.public_key = the_mesh.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
|
||||
|
||||
#ifdef DISPLAY_REDRAW_ON_CHANGE
|
||||
#include <helpers/ui/DisplayFrameSignature.h>
|
||||
@@ -78,6 +131,7 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi
|
||||
_prevBtnState = HIGH;
|
||||
_started_at = millis();
|
||||
_node_prefs = node_prefs;
|
||||
resetRadioProfileDisplayPage();
|
||||
#ifdef DISPLAY_ACTIVITY_DASHBOARD
|
||||
ObserverDashboard::applyDarkPalette(); // retunes UIColor for this target only
|
||||
#endif
|
||||
@@ -109,6 +163,25 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi
|
||||
free(version);
|
||||
}
|
||||
|
||||
void UITask::resetRadioProfileDisplayPage() {
|
||||
_radio_profile_page_started_at = millis();
|
||||
_dual_radio_enabled_seen = the_mesh.isDualRadioActive();
|
||||
}
|
||||
|
||||
bool UITask::showingSecondaryRadioProfilePage() {
|
||||
const bool dual_radio_enabled = the_mesh.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];
|
||||
#ifdef DISPLAY_ACTIVITY_DASHBOARD
|
||||
@@ -196,20 +269,57 @@ void UITask::renderCurrScreen() {
|
||||
renderDashboard();
|
||||
return;
|
||||
#endif
|
||||
const bool dual_radio_enabled = the_mesh.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);
|
||||
|
||||
// freq / sf
|
||||
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);
|
||||
}
|
||||
|
||||
// Compact R1/R2 rows preserve every RF field on 128px displays.
|
||||
_display->setCursor(0, 20);
|
||||
sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf);
|
||||
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);
|
||||
|
||||
// bw / cr
|
||||
_display->setCursor(0, 30);
|
||||
sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr);
|
||||
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);
|
||||
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
@@ -256,9 +366,36 @@ uint32_t UITask::getFrameSignature() {
|
||||
|
||||
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);
|
||||
const mesh::RadioProfiles* profiles = configuredRadioProfiles();
|
||||
const bool dual_radio = profiles != NULL && profiles->enabled();
|
||||
const uint8_t status_pages = radioProfileStatusPageCount(*_display,
|
||||
dual_radio);
|
||||
uint8_t status_page_index = 0;
|
||||
if (mesh::ui::showRadioProfileSystemStatusPage(dual_radio, status_pages,
|
||||
millis() - _radio_profile_page_started_at, &status_page_index)) {
|
||||
snprintf(tmp, sizeof(tmp), "radio-status:%u:%lu", status_page_index,
|
||||
(unsigned long)(millis() / 1000UL));
|
||||
return DisplayFrameSignature::append(signature, tmp);
|
||||
}
|
||||
const bool secondary_page = showingSecondaryRadioProfilePage();
|
||||
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", _node_prefs->bw, _node_prefs->cr);
|
||||
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)
|
||||
@@ -285,6 +422,18 @@ bool UITask::buildDashboardContext(ObserverDashboard::Context* ctx) {
|
||||
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
|
||||
@@ -329,6 +478,7 @@ void UITask::updateActivityRows() {
|
||||
#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
|
||||
@@ -346,6 +496,7 @@ void UITask::toggleDisplay(const char* source) {
|
||||
|
||||
void UITask::loop() {
|
||||
if (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) {
|
||||
resetRadioProfileDisplayPage();
|
||||
_next_refresh = 0;
|
||||
#ifdef DISPLAY_REDRAW_ON_CHANGE
|
||||
_frame_valid = false;
|
||||
@@ -381,6 +532,7 @@ void UITask::loop() {
|
||||
if (ev != BUTTON_EVENT_NONE) {
|
||||
if (!_display->isOn()) {
|
||||
_display->wake(mesh::ui::DisplayWake::Button);
|
||||
resetRadioProfileDisplayPage();
|
||||
#ifdef DISPLAY_REDRAW_ON_CHANGE
|
||||
_frame_valid = false;
|
||||
#endif
|
||||
@@ -403,6 +555,7 @@ void UITask::loop() {
|
||||
// TODO: any action ?
|
||||
} else {
|
||||
_display->wake(mesh::ui::DisplayWake::Button);
|
||||
resetRadioProfileDisplayPage();
|
||||
#ifdef DISPLAY_REDRAW_ON_CHANGE
|
||||
_frame_valid = false;
|
||||
#endif
|
||||
|
||||
@@ -26,6 +26,8 @@ class UITask {
|
||||
NodePrefs* _node_prefs;
|
||||
char _version_info[32];
|
||||
unsigned long _started_at = 0;
|
||||
uint32_t _radio_profile_page_started_at = 0;
|
||||
bool _dual_radio_enabled_seen = false;
|
||||
#ifdef DISPLAY_TOUCH_TOGGLE
|
||||
unsigned long _powering_off_at = 0;
|
||||
#endif
|
||||
@@ -61,6 +63,8 @@ class UITask {
|
||||
uint8_t _flip_seen = 0xFF; // 0xFF forces the first apply
|
||||
void applyDisplayFlip();
|
||||
|
||||
void resetRadioProfileDisplayPage();
|
||||
bool showingSecondaryRadioProfilePage();
|
||||
|
||||
void renderCurrScreen();
|
||||
public:
|
||||
|
||||
@@ -243,3 +243,7 @@ private:
|
||||
}
|
||||
#endif
|
||||
};
|
||||
|
||||
// The display implementation is kept independent of the sketch-local MyMesh
|
||||
// subclass, while still needing its configured radio profiles.
|
||||
SensorMesh& activeSensorMesh();
|
||||
@@ -2,6 +2,57 @@
|
||||
#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
|
||||
@@ -29,6 +80,7 @@ static const uint8_t meshcore_logo [] PROGMEM = {
|
||||
void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) {
|
||||
_prevBtnState = HIGH;
|
||||
_node_prefs = node_prefs;
|
||||
resetRadioProfileDisplayPage();
|
||||
_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected());
|
||||
#if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) \
|
||||
&& defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \
|
||||
@@ -49,6 +101,25 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi
|
||||
free(version);
|
||||
}
|
||||
|
||||
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 (millis() < BOOT_SCREEN_MILLIS) { // boot screen
|
||||
@@ -77,26 +148,66 @@ void UITask::renderCurrScreen() {
|
||||
_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);
|
||||
|
||||
// freq / sf
|
||||
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);
|
||||
}
|
||||
|
||||
// Compact R1/R2 rows preserve every RF field on 128px displays.
|
||||
_display->setCursor(0, 20);
|
||||
sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf);
|
||||
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);
|
||||
|
||||
// bw / cr
|
||||
_display->setCursor(0, 30);
|
||||
sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr);
|
||||
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 (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) _next_refresh = 0;
|
||||
if (_display->servicePower(board.isExternalPowered() || board.isUsbHostConnected())) {
|
||||
resetRadioProfileDisplayPage();
|
||||
_next_refresh = 0;
|
||||
}
|
||||
#if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) \
|
||||
&& defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \
|
||||
&& MOMENTARY_BUTTON_WAKE_FROM_SLEEP
|
||||
@@ -104,6 +215,7 @@ void UITask::loop() {
|
||||
if (ev != BUTTON_EVENT_NONE) {
|
||||
if (!_display->isOn()) {
|
||||
_display->wake(mesh::ui::DisplayWake::Button);
|
||||
resetRadioProfileDisplayPage();
|
||||
_next_refresh = 0;
|
||||
}
|
||||
_display->wake(mesh::ui::DisplayWake::Button);
|
||||
@@ -117,6 +229,7 @@ void UITask::loop() {
|
||||
// TODO: any action ?
|
||||
} else {
|
||||
_display->wake(mesh::ui::DisplayWake::Button);
|
||||
resetRadioProfileDisplayPage();
|
||||
}
|
||||
_display->wake(mesh::ui::DisplayWake::Button); // extend auto-off timer
|
||||
}
|
||||
|
||||
@@ -9,11 +9,15 @@ class UITask {
|
||||
int _prevBtnState;
|
||||
NodePrefs* _node_prefs;
|
||||
char _version_info[32];
|
||||
uint32_t _radio_profile_page_started_at = 0;
|
||||
bool _dual_radio_enabled_seen = false;
|
||||
|
||||
void resetRadioProfileDisplayPage();
|
||||
bool showingSecondaryRadioProfilePage();
|
||||
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);
|
||||
|
||||
void loop();
|
||||
};
|
||||
};
|
||||
@@ -52,6 +52,10 @@ SimpleMeshTables tables;
|
||||
|
||||
MyMesh the_mesh(board, radio_driver, *new ArduinoMillis(), fast_rng, rtc_clock, tables);
|
||||
|
||||
SensorMesh& activeSensorMesh() {
|
||||
return the_mesh;
|
||||
}
|
||||
|
||||
void halt() {
|
||||
while (1) ;
|
||||
}
|
||||
|
||||
@@ -134,6 +134,22 @@ public:
|
||||
virtual float getNoiseFloorDbm() const {
|
||||
return static_cast<float>(getNoiseFloor());
|
||||
}
|
||||
// Profile zero is the primary radio. Radios that scan a second profile can
|
||||
// expose its independent floor without changing the legacy stats API.
|
||||
virtual float getNoiseFloorDbm(uint8_t profile) const {
|
||||
(void)profile;
|
||||
return getNoiseFloorDbm();
|
||||
}
|
||||
|
||||
// A zero result means there is no active sampled calibration window (or
|
||||
// that this transport does not expose one). The estimate itself may remain
|
||||
// visible while a later refresh is being collected.
|
||||
virtual float getNoiseFloorCalibrationSecondsRemaining() const { return 0.0f; }
|
||||
virtual float getNoiseFloorCalibrationSecondsRemaining(uint8_t profile) const {
|
||||
(void)profile;
|
||||
return getNoiseFloorCalibrationSecondsRemaining();
|
||||
}
|
||||
virtual bool isCalibratingNoiseFloor() const { return false; }
|
||||
|
||||
virtual void triggerNoiseFloorCalibrate(int threshold) { }
|
||||
|
||||
|
||||
@@ -793,8 +793,15 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) {
|
||||
_com_prefs_needs_upgrade = false;
|
||||
#endif
|
||||
} else {
|
||||
// File doesn't exist - set default bridge settings for fresh installs
|
||||
// File doesn't exist - set defaults for a fresh install. Dual R1/R2
|
||||
// scanning keeps the node awake, so only that configuration starts with
|
||||
// device power saving off. A saved preference is never rewritten here.
|
||||
is_fresh_install = true;
|
||||
mesh::Radio* profile_radio = _callbacks->getProfileRadio();
|
||||
const bool dual_radio_active = profile_radio != NULL
|
||||
&& profile_radio->profiles() != NULL
|
||||
&& profile_radio->profiles()->enabled();
|
||||
_prefs->powersaving_enabled = DEFAULT_POWERSAVING_ENABLED && !dual_radio_active ? 1 : 0;
|
||||
_prefs->bridge_pkt_src = 1; // Default to RX (logRx) for new installs
|
||||
}
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
#define DEFAULT_CAD_ENABLED 0
|
||||
#endif
|
||||
#ifndef DEFAULT_POWERSAVING_ENABLED
|
||||
#define DEFAULT_POWERSAVING_ENABLED 0
|
||||
#define DEFAULT_POWERSAVING_ENABLED 1
|
||||
#endif
|
||||
|
||||
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
|
||||
@@ -156,7 +156,7 @@ public:
|
||||
uint8_t bridge_channel = 0; // 1-13 (ESP-NOW only)
|
||||
char bridge_secret[16] = {}; // for XOR encryption of bridge packets (ESP-NOW only)
|
||||
// Power setting
|
||||
uint8_t powersaving_enabled = 0; // boolean
|
||||
uint8_t powersaving_enabled = DEFAULT_POWERSAVING_ENABLED ? 1 : 0; // boolean
|
||||
uint8_t reboot_interval = 0; // hours, 0-255 (default 0=disable)
|
||||
// Gps settings
|
||||
uint8_t gps_enabled = 0;
|
||||
|
||||
@@ -22,6 +22,13 @@ private:
|
||||
|
||||
public:
|
||||
uint16_t count() const { return _count; }
|
||||
uint16_t samplesRemaining() const { return SAMPLE_COUNT - _count; }
|
||||
// This is the best-case remaining time: rejected RSSI reads and received
|
||||
// packets deliberately pause the collection window rather than fabricate
|
||||
// samples. It is useful for a compact UI progress indication.
|
||||
float secondsRemaining() const {
|
||||
return samplesRemaining() * (SAMPLE_INTERVAL_MS / 1000.0f);
|
||||
}
|
||||
bool complete() const { return _count == SAMPLE_COUNT; }
|
||||
bool ready(uint32_t now) const {
|
||||
return !complete() && (_count == 0 || uint32_t(now - _last_sample_at) >= SAMPLE_INTERVAL_MS);
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
#define NF_CALIB_INTERVAL_MS 2000UL // match the original 2-second refresh cadence
|
||||
#define NF_CALIB_TIMEOUT_MS NoiseFloorEstimator::WINDOW_TIMEOUT_MS
|
||||
#define NF_CONTINUOUS_TIMEOUT_MS NoiseFloorEstimator::WINDOW_TIMEOUT_MS
|
||||
#define NF_CALIB_SETTLE_MS 20UL // frontend/AGC settle after RX entry
|
||||
#define NF_CALIB_SETTLE_MS RadioLibWrapper::NoiseFloorSettleMillis
|
||||
// A profile normally visited for less than its RSSI settle time stays fast
|
||||
// during ordinary scanning. Refresh its floor with one bounded slow-visit
|
||||
// sample set every fifteen minutes instead.
|
||||
#define NF_FAST_PROFILE_REFRESH_INTERVAL_MS (15UL * 60UL * 1000UL)
|
||||
|
||||
static volatile uint8_t state = STATE_IDLE;
|
||||
|
||||
@@ -64,7 +68,9 @@ void RadioLibWrapper::begin() {
|
||||
|
||||
_noise_floor = 0;
|
||||
_noise_floor_centi_dbm = 0;
|
||||
_noise_floor_secondary_centi_dbm = 0;
|
||||
_noise_floor_valid = false;
|
||||
_noise_floor_secondary_valid = false;
|
||||
_threshold = 0;
|
||||
_cad_enabled = false;
|
||||
_rx_mode_checked_at = millis();
|
||||
@@ -75,6 +81,7 @@ void RadioLibWrapper::begin() {
|
||||
|
||||
// start average out some samples
|
||||
_floor_estimator.reset(true);
|
||||
_secondary_floor_estimator.reset(true);
|
||||
_nf_calib_active = false;
|
||||
_nf_last_calib = 0;
|
||||
_nf_sample_from = 0;
|
||||
@@ -324,6 +331,11 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) {
|
||||
if (!restored || (resume && !isInRecvMode())) _profile_refresh_required = true;
|
||||
}
|
||||
_profile_visit_us = micros();
|
||||
// A profile retune re-enters RX on a different tuple. Do not read its
|
||||
// instantaneous RSSI until the chip family's settle interval has elapsed.
|
||||
if (applied && _nf_refresh_requested) {
|
||||
_nf_sample_from = millis() + NF_CALIB_SETTLE_MS;
|
||||
}
|
||||
const uint32_t elapsed = _profile_visit_us - started;
|
||||
if (elapsed > _profiles.longest_switch_us) _profiles.longest_switch_us = elapsed;
|
||||
return applied ? mesh::RadioParamApplyResult::APPLIED : mesh::RadioParamApplyResult::FAILED;
|
||||
@@ -361,7 +373,15 @@ void RadioLibWrapper::serviceProfileScan() {
|
||||
_rx_ps_enabled = false;
|
||||
_profile_rxps_suspended = true;
|
||||
_nf_calib_active = false;
|
||||
_noise_floor_valid = false; // a single-channel floor cannot describe both channels
|
||||
// Each profile owns a floor. Start fresh, independent baselines so RSSI
|
||||
// values from two frequencies are never blended into one estimate.
|
||||
_noise_floor_valid = false;
|
||||
_noise_floor_secondary_valid = false;
|
||||
_nf_refresh_requested = true;
|
||||
_nf_last_calib = 0;
|
||||
_nf_calib_deadline = 0;
|
||||
_floor_estimator.reset(true);
|
||||
_secondary_floor_estimator.reset(true);
|
||||
_profile_refresh_required = true; // refresh side detectors even if the tuple is unchanged
|
||||
setProfileStandbyWarm(true);
|
||||
endReconfigure(resume);
|
||||
@@ -370,11 +390,32 @@ void RadioLibWrapper::serviceProfileScan() {
|
||||
const bool restart_scan = _profiles.enabled()
|
||||
&& (_profile_scan_generation[0] != _profiles.generation[0]
|
||||
|| _profile_scan_generation[1] != _profiles.generation[1]);
|
||||
if (restart_scan) {
|
||||
// A profile changed while scanning. Discard both partial baselines before
|
||||
// the first visit on the replacement tuples.
|
||||
_noise_floor_valid = false;
|
||||
_noise_floor_secondary_valid = false;
|
||||
_nf_refresh_requested = true;
|
||||
_nf_last_calib = 0;
|
||||
_nf_calib_deadline = 0;
|
||||
_floor_estimator.reset(true);
|
||||
_secondary_floor_estimator.reset(true);
|
||||
}
|
||||
if (!_profiles.enabled()) target = 0;
|
||||
else if (restart_scan) target = _profiles.slowerProfile();
|
||||
else if ((uint32_t)(micros() - _profile_visit_us) >= _profiles.listenUs(
|
||||
_active_profile, profilePreamble(_profiles.slowerProfile()))) {
|
||||
target ^= 1;
|
||||
else {
|
||||
uint32_t visit_us = _profiles.listenUs(
|
||||
_active_profile, profilePreamble(_profiles.slowerProfile()));
|
||||
// A normal visit can be shorter than the RSSI settle interval on some
|
||||
// chips. Hold only a visit that is actually due to contribute its next
|
||||
// 50ms-spaced sample; very fast profiles otherwise retain their ordinary
|
||||
// scan cadence throughout calibration.
|
||||
const NoiseFloorEstimator& estimator = profileFloorEstimator(_active_profile);
|
||||
if (_nf_refresh_requested && estimator.ready(static_cast<uint32_t>(millis()))
|
||||
&& visit_us < NF_CALIB_SETTLE_MS * 1000UL) {
|
||||
visit_us = NF_CALIB_SETTLE_MS * 1000UL;
|
||||
}
|
||||
if ((uint32_t)(micros() - _profile_visit_us) >= visit_us) target ^= 1;
|
||||
}
|
||||
const auto result = tuneProfile(target);
|
||||
if (restart_scan && result == mesh::RadioParamApplyResult::APPLIED) {
|
||||
@@ -466,9 +507,22 @@ void RadioLibWrapper::idle() {
|
||||
|
||||
void RadioLibWrapper::triggerNoiseFloorCalibrate(int threshold) {
|
||||
_threshold = threshold;
|
||||
// Calibration is independent of interference detection. Callers such as the
|
||||
// Dispatcher and KISS modem use a zero threshold but still expect a fresh
|
||||
// floor measurement on every scheduled request.
|
||||
// The Dispatcher calls this every two seconds. Repeatedly recalibrating a
|
||||
// dual-profile scanner would repeatedly lengthen its visits. Once both
|
||||
// baselines are valid, retain them until a real RF-path change—except for a
|
||||
// fast profile: it receives one new bounded 7 ms-visit sample set every
|
||||
// fifteen minutes. Profiles already visiting for at least 7 ms need no
|
||||
// artificial slowdown after their normal baseline.
|
||||
if (_profiles.enabled() && _noise_floor_valid && _noise_floor_secondary_valid) {
|
||||
const uint16_t preamble = profilePreamble(_profiles.slowerProfile());
|
||||
const bool fast_profile = _profiles.listenUs(0, preamble) < NF_CALIB_SETTLE_MS * 1000UL
|
||||
|| _profiles.listenUs(1, preamble) < NF_CALIB_SETTLE_MS * 1000UL;
|
||||
if (fast_profile && _nf_last_calib != 0
|
||||
&& (uint32_t)(millis() - _nf_last_calib) >= NF_FAST_PROFILE_REFRESH_INTERVAL_MS) {
|
||||
requestNoiseFloorRefresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
requestNoiseFloorRefresh();
|
||||
}
|
||||
|
||||
@@ -476,9 +530,11 @@ void RadioLibWrapper::recalibrateNoiseFloor() {
|
||||
// A gain or tuning change starts a new baseline. Keep the published value
|
||||
// available while discarding the old samples and rise-hold history.
|
||||
_noise_floor_valid = false;
|
||||
_noise_floor_secondary_valid = false;
|
||||
_nf_refresh_requested = true;
|
||||
_nf_last_calib = 0;
|
||||
_floor_estimator.reset(true);
|
||||
_secondary_floor_estimator.reset(true);
|
||||
|
||||
const unsigned long now = millis();
|
||||
_nf_sample_from = now + NF_CALIB_SETTLE_MS;
|
||||
@@ -497,6 +553,7 @@ void RadioLibWrapper::requestNoiseFloorRefresh() {
|
||||
if (_nf_refresh_requested) return;
|
||||
_nf_refresh_requested = true;
|
||||
_floor_estimator.reset();
|
||||
_secondary_floor_estimator.reset();
|
||||
_nf_calib_deadline = 0; // starts when continuous RX is actually available
|
||||
}
|
||||
|
||||
@@ -517,8 +574,10 @@ void RadioLibWrapper::resetAGC() {
|
||||
// for LBT while the next spaced block is collected. Gain/tuning changes use
|
||||
// recalibrateNoiseFloor() directly to seed a fresh baseline.
|
||||
const bool previous_valid = _noise_floor_valid;
|
||||
const bool previous_secondary_valid = _noise_floor_secondary_valid;
|
||||
recalibrateNoiseFloor();
|
||||
_noise_floor_valid = previous_valid;
|
||||
_noise_floor_secondary_valid = previous_secondary_valid;
|
||||
}
|
||||
|
||||
bool RadioLibWrapper::recoverRadio(bool hard) {
|
||||
@@ -815,13 +874,69 @@ bool RadioLibWrapper::serviceCarrierWave() {
|
||||
|
||||
void RadioLibWrapper::loop() {
|
||||
if (serviceCarrierWave()) return;
|
||||
serviceProfileScan();
|
||||
// Calibration batches need one stable channel. Do not publish a noise floor
|
||||
// assembled from different frequencies, or let a batch pin the scan on one.
|
||||
if (_profiles.enabled()) {
|
||||
checkReceiveMode(static_cast<uint32_t>(millis()));
|
||||
const unsigned long now = millis();
|
||||
checkReceiveMode(static_cast<uint32_t>(now));
|
||||
|
||||
const bool restart_scan = _profile_scan_generation[0] != _profiles.generation[0]
|
||||
|| _profile_scan_generation[1] != _profiles.generation[1];
|
||||
if (!_profile_rxps_suspended || restart_scan) {
|
||||
// Entering scan mode or changing a profile retunes before sampling. A
|
||||
// sample from the previous physical tuple would be misleading.
|
||||
serviceProfileScan();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_nf_refresh_requested && _nf_calib_deadline == 0 && state == STATE_RX) {
|
||||
_nf_calib_deadline = now + NF_CONTINUOUS_TIMEOUT_MS;
|
||||
}
|
||||
if (_nf_refresh_requested && _floor_estimator.complete()
|
||||
&& _secondary_floor_estimator.complete()) {
|
||||
if (_floor_estimator.publish(_noise_floor_centi_dbm, _noise_floor_valid)) {
|
||||
_noise_floor_valid = true;
|
||||
_noise_floor = (_noise_floor_centi_dbm - 50) / 100;
|
||||
}
|
||||
if (_secondary_floor_estimator.publish(_noise_floor_secondary_centi_dbm,
|
||||
_noise_floor_secondary_valid)) {
|
||||
_noise_floor_secondary_valid = true;
|
||||
}
|
||||
_nf_refresh_requested = false;
|
||||
_nf_last_calib = now;
|
||||
_nf_calib_deadline = 0;
|
||||
MESH_DEBUG_PRINTLN("RadioLibWrapper: profile noise floors = %d, %d",
|
||||
(int)_noise_floor,
|
||||
(int)((_noise_floor_secondary_centi_dbm - 50) / 100));
|
||||
} else if (_nf_refresh_requested && _nf_calib_deadline != 0
|
||||
&& (long)(now - _nf_calib_deadline) >= 0) {
|
||||
// Busy profile visits must not pin dual-profile scanning indefinitely.
|
||||
_nf_refresh_requested = false;
|
||||
_nf_last_calib = now;
|
||||
_nf_calib_deadline = 0;
|
||||
_floor_estimator.reset();
|
||||
_secondary_floor_estimator.reset();
|
||||
} else {
|
||||
NoiseFloorEstimator& estimator = profileFloorEstimator(_active_profile);
|
||||
if (_nf_refresh_requested && state == STATE_RX
|
||||
&& !estimator.complete() && !_rx_ps_armed
|
||||
&& estimator.ready(static_cast<uint32_t>(now))
|
||||
&& !(_nf_sample_from != 0 && (long)(now - _nf_sample_from) < 0)
|
||||
&& !isChipBusy() && !isPacketPendingOrReceiving()) {
|
||||
// Preserve samples across visits, but never let one profile's RSSI
|
||||
// contaminate the other profile's independent noise-floor estimate.
|
||||
_nf_sample_from = now + NoiseFloorEstimator::SAMPLE_INTERVAL_MS;
|
||||
if (readReceiveMode() == 0) {
|
||||
estimator.reset(true);
|
||||
} else if (!isPacketPendingOrReceiving()) {
|
||||
estimator.add(getCurrentRSSI(), static_cast<uint32_t>(now));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sample before deciding whether to hop. serviceProfileScan() extends a
|
||||
// short visit only when the active profile still needs a safe sample.
|
||||
serviceProfileScan();
|
||||
return;
|
||||
}
|
||||
serviceProfileScan();
|
||||
if (_rx_ps_enabled && !_rx_ps_continuous_fallback) {
|
||||
rxPsWatchdogCheck();
|
||||
}
|
||||
|
||||
@@ -33,12 +33,15 @@ protected:
|
||||
uint32_t n_recv, n_sent, n_recv_errors;
|
||||
int16_t _noise_floor, _threshold;
|
||||
int32_t _noise_floor_centi_dbm;
|
||||
int32_t _noise_floor_secondary_centi_dbm = 0;
|
||||
float _last_rssi, _last_snr;
|
||||
bool _cad_enabled;
|
||||
uint32_t _cad_scan_timeout_override_ms;
|
||||
bool _noise_floor_valid;
|
||||
bool _noise_floor_secondary_valid = false;
|
||||
bool _nf_refresh_requested;
|
||||
NoiseFloorEstimator _floor_estimator;
|
||||
NoiseFloorEstimator _secondary_floor_estimator;
|
||||
uint32_t _rx_mode_checked_at = 0;
|
||||
uint8_t _rx_mode_failures = 0;
|
||||
unsigned long last_recv_millis;
|
||||
@@ -101,6 +104,43 @@ protected:
|
||||
unsigned long _nf_last_calib; // millis of last completed/attempted window
|
||||
unsigned long _nf_calib_deadline; // abort window if the batch can't complete
|
||||
unsigned long _nf_sample_from; // no samples before this (RX entry settle)
|
||||
// The T096 SX1262 bench test found RSSI valid and stable by 7 ms after RX
|
||||
// entry, including the 500 kHz/SF5 worst-case profile. Retain the
|
||||
// conservative 20 ms path for the other radio families.
|
||||
#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LLCC68)
|
||||
static constexpr uint32_t NoiseFloorSettleMillis = 7UL;
|
||||
#else
|
||||
static constexpr uint32_t NoiseFloorSettleMillis = 20UL;
|
||||
#endif
|
||||
|
||||
NoiseFloorEstimator& profileFloorEstimator(uint8_t profile) {
|
||||
return profile == 1 ? _secondary_floor_estimator : _floor_estimator;
|
||||
}
|
||||
const NoiseFloorEstimator& profileFloorEstimator(uint8_t profile) const {
|
||||
return profile == 1 ? _secondary_floor_estimator : _floor_estimator;
|
||||
}
|
||||
float profileNoiseFloorSecondsRemaining(uint8_t profile) const {
|
||||
if (!isCalibratingNoiseFloor()) return 0.0f;
|
||||
const NoiseFloorEstimator& estimator = profileFloorEstimator(profile);
|
||||
if (!_profiles.enabled()) return estimator.secondsRemaining();
|
||||
|
||||
// Each profile receives one sample per complete scan cycle. The floor
|
||||
// that belongs to a short visit may wait only as long as the chip family
|
||||
// needs for RSSI settling; the UI must follow this true cadence.
|
||||
const uint32_t preamble = profilePreamble(_profiles.slowerProfile());
|
||||
uint32_t primary_visit = _profiles.listenUs(0, preamble);
|
||||
uint32_t secondary_visit = _profiles.listenUs(1, preamble);
|
||||
const uint32_t minimum_visit = NoiseFloorSettleMillis * 1000UL;
|
||||
if (primary_visit < minimum_visit) primary_visit = minimum_visit;
|
||||
if (secondary_visit < minimum_visit) secondary_visit = minimum_visit;
|
||||
uint32_t switch_us = _profiles.longest_switch_us;
|
||||
if (switch_us < mesh::RadioProfiles::SwitchBudgetUs) {
|
||||
switch_us = mesh::RadioProfiles::SwitchBudgetUs;
|
||||
}
|
||||
const uint64_t cycle_us = uint64_t(primary_visit) + secondary_visit
|
||||
+ 2ULL * switch_us + mesh::RadioProfiles::LoopBudgetUs;
|
||||
return estimator.samplesRemaining() * (cycle_us / 1000000.0f);
|
||||
}
|
||||
|
||||
static constexpr bool hasDirectRadioResetPin() {
|
||||
#if defined(P_LORA_RESET)
|
||||
@@ -270,6 +310,21 @@ public:
|
||||
float getNoiseFloorDbm() const override {
|
||||
return _noise_floor_centi_dbm / 100.0f;
|
||||
}
|
||||
float getNoiseFloorDbm(uint8_t profile) const override {
|
||||
if (profile == 1 && _profiles.enabled()) {
|
||||
return _noise_floor_secondary_centi_dbm / 100.0f;
|
||||
}
|
||||
return getNoiseFloorDbm();
|
||||
}
|
||||
float getNoiseFloorCalibrationSecondsRemaining() const override {
|
||||
const float primary = profileNoiseFloorSecondsRemaining(0);
|
||||
if (!_profiles.enabled()) return primary;
|
||||
const float secondary = profileNoiseFloorSecondsRemaining(1);
|
||||
return primary > secondary ? primary : secondary;
|
||||
}
|
||||
float getNoiseFloorCalibrationSecondsRemaining(uint8_t profile) const override {
|
||||
return profileNoiseFloorSecondsRemaining(profile);
|
||||
}
|
||||
void triggerNoiseFloorCalibrate(int threshold) override;
|
||||
void recalibrateNoiseFloor() override;
|
||||
void setCADEnabled(bool enable) override { _cad_enabled = enable; }
|
||||
@@ -300,7 +355,7 @@ public:
|
||||
bool isWatchdogObserving() const { return _wd_observe_until != 0; }
|
||||
// true while a noise-floor batch needs prompt loop service; the app's
|
||||
// hasPendingWork() keeps the MCU awake for the bounded spaced-sample window.
|
||||
bool isCalibratingNoiseFloor() const {
|
||||
bool isCalibratingNoiseFloor() const override {
|
||||
return _nf_calib_active
|
||||
|| (_nf_refresh_requested
|
||||
&& (!_rx_ps_enabled || _rx_ps_continuous_fallback));
|
||||
|
||||
@@ -168,6 +168,16 @@ public:
|
||||
BoundedTextRows(DisplayDriver& d, const DisplayRegion& r, int spacing = 2)
|
||||
: display(d), region(r), next_y(r.y), gap(spacing) {}
|
||||
|
||||
int nextY() const { return next_y; }
|
||||
|
||||
bool reserve() {
|
||||
const int height = display.textLineHeight();
|
||||
if (height <= 0 || !displayRegionContainsLine(region, next_y, height))
|
||||
return false;
|
||||
next_y += height + gap;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool draw(const char* text, bool centered = true) {
|
||||
const int height = display.textLineHeight();
|
||||
if (height <= 0 || !displayRegionContainsLine(region, next_y, height))
|
||||
|
||||
@@ -241,7 +241,9 @@ struct Context {
|
||||
float freq;
|
||||
uint8_t sf;
|
||||
float bw;
|
||||
const char* radio_label; // "" for the sole/default profile, otherwise R1/R2/T1/T2
|
||||
bool link_up;
|
||||
bool dual_radio;
|
||||
};
|
||||
|
||||
struct RowText {
|
||||
@@ -316,7 +318,11 @@ inline void composeRow(Row row, const Context& ctx, const RadioActivitySnapshot&
|
||||
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 --");
|
||||
if (ctx.dual_radio) {
|
||||
snprintf(out->right, sizeof(out->right), ctx.link_up ? "WiFi OK R2 ON" : "WiFi -- R2 ON");
|
||||
} else {
|
||||
snprintf(out->right, sizeof(out->right), ctx.link_up ? "WiFi OK" : "WiFi --");
|
||||
}
|
||||
out->right_color = ctx.link_up ? GOOD : WARN;
|
||||
break;
|
||||
|
||||
@@ -485,7 +491,15 @@ inline void drawHeader(DisplayDriver& d, const Layout& l, const Context& ctx) {
|
||||
|
||||
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 params[32];
|
||||
formatRadioStrip(params, sizeof(params), ctx.freq, ctx.sf, ctx.bw);
|
||||
if (ctx.radio_label && ctx.radio_label[0]) {
|
||||
// Tags are R1/R2/T1/T2. Bounds keep this header-only layout safe even if
|
||||
// a future caller supplies a longer diagnostic label.
|
||||
snprintf(tmp, sizeof(tmp), "%.3s %.27s", ctx.radio_label, params);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "%s", params);
|
||||
}
|
||||
char fitted[32];
|
||||
fitToChars(fitted, sizeof(fitted), tmp, l.max_chars);
|
||||
d.setTextSize(1);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
namespace mesh { namespace ui {
|
||||
|
||||
// Keep each configured radio profile visible long enough to read, while still
|
||||
// fitting both into the normal short display wake interval.
|
||||
constexpr uint32_t RADIO_PROFILE_DISPLAY_PAGE_MILLIS = 7000;
|
||||
|
||||
// The ordinary repeater home page needs two RF pages when radio2 is active.
|
||||
// Compact panels use the following page(s) for the settings that do not fit
|
||||
// beside F/B/S/C. The caller supplies the number of status pages required by
|
||||
// its measured font height and display height.
|
||||
inline uint8_t radioProfileDisplayPageCount(bool dual_radio_enabled,
|
||||
uint8_t status_page_count) {
|
||||
return (dual_radio_enabled ? 2 : 1) + status_page_count;
|
||||
}
|
||||
|
||||
inline uint8_t radioProfileDisplayPageIndex(bool dual_radio_enabled,
|
||||
uint8_t status_page_count,
|
||||
uint32_t elapsed_millis) {
|
||||
const uint8_t page_count = radioProfileDisplayPageCount(
|
||||
dual_radio_enabled, status_page_count);
|
||||
return page_count == 0 ? 0
|
||||
: (elapsed_millis / RADIO_PROFILE_DISPLAY_PAGE_MILLIS) % page_count;
|
||||
}
|
||||
|
||||
// Repeater screens advance these pages with a physical button. Keeping the
|
||||
// manual helpers alongside the timed compatibility helpers makes it explicit
|
||||
// that a button press, rather than a timeout, owns the selected profile.
|
||||
inline uint8_t radioProfileManualPageIndex(bool dual_radio_enabled,
|
||||
uint8_t status_page_count,
|
||||
uint8_t selected_page) {
|
||||
const uint8_t page_count = radioProfileDisplayPageCount(
|
||||
dual_radio_enabled, status_page_count);
|
||||
return page_count == 0 ? 0 : selected_page % page_count;
|
||||
}
|
||||
|
||||
inline bool showRadioProfileSystemStatusPage(bool dual_radio_enabled,
|
||||
uint8_t status_page_count,
|
||||
uint32_t elapsed_millis,
|
||||
uint8_t* status_page_index = nullptr) {
|
||||
const uint8_t radio_page_count = dual_radio_enabled ? 2 : 1;
|
||||
const uint8_t page_index = radioProfileDisplayPageIndex(
|
||||
dual_radio_enabled, status_page_count, elapsed_millis);
|
||||
const bool is_status_page = status_page_count != 0
|
||||
&& page_index >= radio_page_count;
|
||||
if (status_page_index != nullptr) {
|
||||
*status_page_index = is_status_page ? page_index - radio_page_count : 0;
|
||||
}
|
||||
return is_status_page;
|
||||
}
|
||||
|
||||
inline bool showManualRadioProfileSystemStatusPage(bool dual_radio_enabled,
|
||||
uint8_t status_page_count,
|
||||
uint8_t selected_page,
|
||||
uint8_t* status_page_index = nullptr) {
|
||||
const uint8_t radio_page_count = dual_radio_enabled ? 2 : 1;
|
||||
const uint8_t page_index = radioProfileManualPageIndex(
|
||||
dual_radio_enabled, status_page_count, selected_page);
|
||||
const bool is_status_page = status_page_count != 0
|
||||
&& page_index >= radio_page_count;
|
||||
if (status_page_index != nullptr) {
|
||||
*status_page_index = is_status_page ? page_index - radio_page_count : 0;
|
||||
}
|
||||
return is_status_page;
|
||||
}
|
||||
|
||||
inline bool showSecondaryRadioProfilePage(bool dual_radio_enabled,
|
||||
uint8_t status_page_count,
|
||||
uint32_t elapsed_millis) {
|
||||
return dual_radio_enabled && radioProfileDisplayPageIndex(
|
||||
dual_radio_enabled, status_page_count, elapsed_millis) == 1;
|
||||
}
|
||||
|
||||
inline bool showManualSecondaryRadioProfilePage(bool dual_radio_enabled,
|
||||
uint8_t selected_page) {
|
||||
return dual_radio_enabled && selected_page == 1;
|
||||
}
|
||||
|
||||
inline bool showSecondaryRadioProfilePage(bool dual_radio_enabled,
|
||||
uint32_t elapsed_millis) {
|
||||
return dual_radio_enabled
|
||||
&& ((elapsed_millis / RADIO_PROFILE_DISPLAY_PAGE_MILLIS) & 1U) != 0;
|
||||
}
|
||||
|
||||
inline const char* radioProfileDisplayTag(bool secondary, bool temporary) {
|
||||
if (secondary) return temporary ? "T2" : "R2";
|
||||
return temporary ? "T1" : "R1";
|
||||
}
|
||||
|
||||
} } // namespace mesh::ui
|
||||
@@ -0,0 +1,252 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#include <RadioProfiles.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/ui/RadioProfileDisplayPage.h>
|
||||
|
||||
namespace mesh { namespace ui {
|
||||
|
||||
// A compact, paged rendering of the radio/system state shared by the role
|
||||
// UIs. Keeping the rows discrete lets a 72x40 screen page them safely while
|
||||
// a 128x64 panel uses only two status pages.
|
||||
struct RadioProfileSystemStatus {
|
||||
const uint8_t* public_key = nullptr;
|
||||
bool powersaving_enabled = false;
|
||||
bool gps_enabled = false;
|
||||
bool fem_enabled = false;
|
||||
bool rx_boosted_gain = false;
|
||||
bool rx_powersaving_enabled = false;
|
||||
bool cad_enabled = false;
|
||||
bool dual_radio_enabled = false;
|
||||
bool secondary_temporary = false;
|
||||
RadioProfileMode secondary_mode = RadioProfileMode::Rx;
|
||||
RadioCrossMode cross = RadioCrossMode::Auto;
|
||||
float noise_floor_1 = 0.0f;
|
||||
float noise_floor_2 = 0.0f;
|
||||
float noise_floor_1_seconds = 0.0f;
|
||||
float noise_floor_2_seconds = 0.0f;
|
||||
};
|
||||
|
||||
inline uint8_t radioProfileSystemStatusRowCount(bool dual_radio_enabled,
|
||||
bool narrow = false) {
|
||||
// ID, PS/GPS, FEM/RXB, RXPS/CAD, N1, plus R2 mode, cross, and N2.
|
||||
// Narrow panels cannot fit a state pair on one line, so each switch gets
|
||||
// its own row instead of clipping the right-hand state.
|
||||
if (narrow) return dual_radio_enabled ? 11 : 8;
|
||||
return dual_radio_enabled ? 8 : 5;
|
||||
}
|
||||
|
||||
inline uint8_t radioProfileSystemStatusRowsPerPage(
|
||||
DisplayDriver& display, int top = 0) {
|
||||
const int line_height = display.textLineHeight();
|
||||
if (line_height <= 0 || top >= display.height()) return 1;
|
||||
const int row_height = line_height + 2;
|
||||
const int rows = (display.height() - top + 2) / row_height;
|
||||
return rows > 0 ? rows : 1;
|
||||
}
|
||||
|
||||
inline uint8_t radioProfileSystemStatusPageCount(
|
||||
DisplayDriver& display, bool dual_radio_enabled, int top = 0) {
|
||||
const uint8_t rows = radioProfileSystemStatusRowsPerPage(display, top);
|
||||
const uint8_t count = radioProfileSystemStatusRowCount(
|
||||
dual_radio_enabled, display.width() < 104);
|
||||
return (count + rows - 1) / rows;
|
||||
}
|
||||
|
||||
inline int drawRadioProfileStatusState(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);
|
||||
}
|
||||
|
||||
inline void drawRadioProfileStatusPair(DisplayDriver& display, int y,
|
||||
const char* first_label,
|
||||
bool first_enabled,
|
||||
const char* second_label,
|
||||
bool second_enabled) {
|
||||
const int x = drawRadioProfileStatusState(display, 0, y, first_label,
|
||||
first_enabled);
|
||||
drawRadioProfileStatusState(display, x + display.getTextWidth(" "), y,
|
||||
second_label, second_enabled);
|
||||
display.setColor(UIColor::primary_txt);
|
||||
}
|
||||
|
||||
inline void formatRadioProfileNoiseFloor(char* buffer, size_t size,
|
||||
const char* label, float dbm,
|
||||
float seconds_remaining) {
|
||||
if (dbm != 0.0f) {
|
||||
snprintf(buffer, size, "%s:%.1f", label, dbm);
|
||||
} else if (seconds_remaining > 0.0f) {
|
||||
snprintf(buffer, size, "%s:%.1fs", label, seconds_remaining);
|
||||
} else {
|
||||
snprintf(buffer, size, "%s:WAIT", label);
|
||||
}
|
||||
}
|
||||
|
||||
inline void drawRadioProfileSystemStatusPage(
|
||||
DisplayDriver& display, const RadioProfileSystemStatus& status,
|
||||
uint8_t page_index, int top = 0) {
|
||||
display.setTextSize(1);
|
||||
const uint8_t rows_per_page = radioProfileSystemStatusRowsPerPage(display,
|
||||
top);
|
||||
const uint8_t first_row = page_index * rows_per_page;
|
||||
// The widest paired row (RXPS/CAD) occupies 102 pixels in the standard
|
||||
// six-pixel font, so 96-pixel panels must use the narrow layout too.
|
||||
const bool narrow = display.width() < 104;
|
||||
const uint8_t row_count = radioProfileSystemStatusRowCount(
|
||||
status.dual_radio_enabled, narrow);
|
||||
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 = top + (row - first_row) * row_height;
|
||||
if (narrow) {
|
||||
if (row == 0) {
|
||||
char identity[10] = "ID:------";
|
||||
if (status.public_key != nullptr) {
|
||||
snprintf(identity, sizeof(identity), "ID:%02X%02X%02X",
|
||||
status.public_key[0], status.public_key[1],
|
||||
status.public_key[2]);
|
||||
}
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.setCursor(0, y);
|
||||
display.print(identity);
|
||||
} else if (row == 1) {
|
||||
drawRadioProfileStatusState(display, 0, y, "PS:",
|
||||
status.powersaving_enabled);
|
||||
} else if (row == 2) {
|
||||
drawRadioProfileStatusState(display, 0, y, "GPS:",
|
||||
status.gps_enabled);
|
||||
} else if (row == 3) {
|
||||
drawRadioProfileStatusState(display, 0, y, "FEM:",
|
||||
status.fem_enabled);
|
||||
} else if (row == 4) {
|
||||
drawRadioProfileStatusState(display, 0, y, "RXB:",
|
||||
status.rx_boosted_gain);
|
||||
} else if (row == 5) {
|
||||
drawRadioProfileStatusState(display, 0, y, "RXPS:",
|
||||
status.rx_powersaving_enabled);
|
||||
} else if (row == 6) {
|
||||
drawRadioProfileStatusState(display, 0, y, "CAD:",
|
||||
status.cad_enabled);
|
||||
} else if (status.dual_radio_enabled && row == 7) {
|
||||
char mode[12];
|
||||
snprintf(mode, sizeof(mode), "%s:%s",
|
||||
radioProfileDisplayTag(true, status.secondary_temporary),
|
||||
status.secondary_mode == RadioProfileMode::RxTx ? "RXTX"
|
||||
: "RX");
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.setCursor(0, y);
|
||||
display.print(mode);
|
||||
} else if (status.dual_radio_enabled && row == 8) {
|
||||
const char* cross = status.cross == RadioCrossMode::On ? "X:ON"
|
||||
: status.cross == RadioCrossMode::Off ? "X:OFF" : "X:AUTO";
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.setCursor(0, y);
|
||||
display.print(cross);
|
||||
} else {
|
||||
const bool second_noise = status.dual_radio_enabled && row == 10;
|
||||
char noise[12];
|
||||
formatRadioProfileNoiseFloor(noise, sizeof(noise),
|
||||
second_noise ? "N2" : "N1",
|
||||
second_noise ? status.noise_floor_2 : status.noise_floor_1,
|
||||
second_noise ? status.noise_floor_2_seconds
|
||||
: status.noise_floor_1_seconds);
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.setCursor(0, y);
|
||||
display.print(noise);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
switch (row) {
|
||||
case 0: {
|
||||
char identity[10] = "ID:------";
|
||||
if (status.public_key != nullptr) {
|
||||
snprintf(identity, sizeof(identity), "ID:%02X%02X%02X",
|
||||
status.public_key[0], status.public_key[1],
|
||||
status.public_key[2]);
|
||||
}
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.setCursor(0, y);
|
||||
display.print(identity);
|
||||
break;
|
||||
}
|
||||
case 1:
|
||||
drawRadioProfileStatusPair(display, y, "PS:",
|
||||
status.powersaving_enabled, "GPS:",
|
||||
status.gps_enabled);
|
||||
break;
|
||||
case 2:
|
||||
drawRadioProfileStatusPair(display, y, "FEM:", status.fem_enabled,
|
||||
"RXB:", status.rx_boosted_gain);
|
||||
break;
|
||||
case 3:
|
||||
drawRadioProfileStatusPair(display, y, "RXPS:",
|
||||
status.rx_powersaving_enabled, "CAD:",
|
||||
status.cad_enabled);
|
||||
break;
|
||||
case 4:
|
||||
if (status.dual_radio_enabled) {
|
||||
char mode[12];
|
||||
snprintf(mode, sizeof(mode), "%s:%s",
|
||||
radioProfileDisplayTag(true, status.secondary_temporary),
|
||||
status.secondary_mode == RadioProfileMode::RxTx ? "RXTX"
|
||||
: "RX");
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.setCursor(0, y);
|
||||
display.print(mode);
|
||||
} else {
|
||||
char noise[12];
|
||||
formatRadioProfileNoiseFloor(noise, sizeof(noise), "N1",
|
||||
status.noise_floor_1,
|
||||
status.noise_floor_1_seconds);
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.setCursor(0, y);
|
||||
display.print(noise);
|
||||
}
|
||||
break;
|
||||
case 5: {
|
||||
const char* cross = status.cross == RadioCrossMode::On ? "X:ON"
|
||||
: status.cross == RadioCrossMode::Off ? "X:OFF" : "X:AUTO";
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.setCursor(0, y);
|
||||
display.print(cross);
|
||||
break;
|
||||
}
|
||||
case 6: {
|
||||
char noise[12];
|
||||
formatRadioProfileNoiseFloor(noise, sizeof(noise), "N1",
|
||||
status.noise_floor_1,
|
||||
status.noise_floor_1_seconds);
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.setCursor(0, y);
|
||||
display.print(noise);
|
||||
break;
|
||||
}
|
||||
case 7: {
|
||||
char noise[12];
|
||||
formatRadioProfileNoiseFloor(noise, sizeof(noise), "N2",
|
||||
status.noise_floor_2,
|
||||
status.noise_floor_2_seconds);
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.setCursor(0, y);
|
||||
display.print(noise);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
display.setColor(UIColor::primary_txt);
|
||||
}
|
||||
|
||||
} } // namespace mesh::ui
|
||||
+38
@@ -64,6 +64,44 @@ int main() {
|
||||
|
||||
mesh::ui::SmallMessageText body(display);
|
||||
assert(body.capitalHeight() == 6);
|
||||
// The repeater's T096 status column uses the driver's normal 6x8 font.
|
||||
const char* status_rows[] = {"GPS:OFF", "FEM:OFF", "RXB:OFF", "RXPS:OFF", "CAD:OFF"};
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
assert(display.getTextWidth(status_rows[i]) <= 50);
|
||||
display.clear();
|
||||
display.drawTextRightAlign(158, 10 + (i < 4 ? i : 5) * 10, status_rows[i]);
|
||||
assert(canvas.x >= 108 && canvas.x + display.getTextWidth(status_rows[i]) <= 158);
|
||||
}
|
||||
const char* noise_floor_rows[] = {"N1:-123.4", "N2:-123.4", "N1:4.2s", "N2:WAIT"};
|
||||
for (const char* noise_floor : noise_floor_rows) {
|
||||
// Decimal per-radio floors need 54px. The final T096 row has no wide
|
||||
// left-column field, so it may use four pixels of its empty gutter.
|
||||
assert(display.getTextWidth(noise_floor) <= 54);
|
||||
display.clear();
|
||||
display.drawTextRightAlign(158, 70, noise_floor);
|
||||
assert(canvas.x >= 104 && canvas.x + display.getTextWidth(noise_floor) <= 158);
|
||||
}
|
||||
const char* radio_tags[] = {"R2:ON", "T2:ON"};
|
||||
for (const char* tag : radio_tags) {
|
||||
assert(display.getTextWidth(tag) <= 50);
|
||||
display.clear();
|
||||
display.drawTextRightAlign(158, 50, tag);
|
||||
assert(canvas.x >= 108 && canvas.x + display.getTextWidth(tag) <= 158);
|
||||
}
|
||||
const char* dual_radio_rows[] = {"R1 F:2500.000 S:12", "T1 B:1625.00 C:8",
|
||||
"R2 F:2500.000 S:12", "T2 B:1625.00 C:8"};
|
||||
for (const char* row : dual_radio_rows) {
|
||||
assert(display.getTextWidth(row) <= 108);
|
||||
}
|
||||
assert(display.getTextWidth("ID:ABCDEF") <= 108);
|
||||
assert(display.getTextWidth("R2:RXTX") <= 108);
|
||||
assert(display.getTextWidth("X:AUTO") <= 108);
|
||||
// The name owns the full normal-font top row; radio/status columns start
|
||||
// below it and cannot cause name wrapping.
|
||||
canvas.prints = 0;
|
||||
assert(display.getTextWidth("MercerWoodMesh T096 Node") <= 160);
|
||||
display.drawTextEllipsized(0, 0, 160, "MercerWoodMesh T096 Node");
|
||||
assert(canvas.prints == 1);
|
||||
#if defined(HELTEC_T096)
|
||||
assert(body.lineCount(12) == 8);
|
||||
// Check all 95 real font glyphs against their native bitmap coordinates.
|
||||
|
||||
@@ -19,6 +19,18 @@ TEST(NoiseFloorEstimator, RequiresACompleteSpacedBlock) {
|
||||
EXPECT_GT(NoiseFloorEstimator::WINDOW_TIMEOUT_MS, 3150U);
|
||||
}
|
||||
|
||||
TEST(NoiseFloorEstimator, ReportsBestCaseRemainingSecondsWithTenths) {
|
||||
NoiseFloorEstimator n;
|
||||
EXPECT_EQ(64, n.samplesRemaining());
|
||||
EXPECT_FLOAT_EQ(3.2f, n.secondsRemaining());
|
||||
for (unsigned i = 0; i < 20; ++i) ASSERT_TRUE(n.add(-100, i * 50));
|
||||
EXPECT_EQ(44, n.samplesRemaining());
|
||||
EXPECT_FLOAT_EQ(2.2f, n.secondsRemaining());
|
||||
for (unsigned i = 20; i < 64; ++i) ASSERT_TRUE(n.add(-100, i * 50));
|
||||
EXPECT_EQ(0, n.samplesRemaining());
|
||||
EXPECT_FLOAT_EQ(0.0f, n.secondsRemaining());
|
||||
}
|
||||
|
||||
TEST(NoiseFloorEstimator, RejectsSparseLowOutliersAndMajorityTraffic) {
|
||||
NoiseFloorEstimator n;
|
||||
int32_t floor = 0;
|
||||
|
||||
@@ -39,7 +39,9 @@ Context makeContext(const char* name = "Ridgeline North") {
|
||||
c.freq = 910.525f;
|
||||
c.sf = 7;
|
||||
c.bw = 62.5f;
|
||||
c.radio_label = "";
|
||||
c.link_up = true;
|
||||
c.dual_radio = false;
|
||||
return c;
|
||||
}
|
||||
|
||||
@@ -157,6 +159,18 @@ TEST(ObserverDashboardFormat, EmptyWindowNeverProducesNanOrInfinity) {
|
||||
EXPECT_STREQ("RX --", status.left);
|
||||
}
|
||||
|
||||
TEST(ObserverDashboardFormat, StatusRowIdentifiesAnActiveSecondRadio) {
|
||||
Context ctx = makeContext();
|
||||
ctx.dual_radio = true;
|
||||
RowText status;
|
||||
composeRow(ROW_STATUS, ctx, makeEmpty(), &status);
|
||||
EXPECT_STREQ("WiFi OK R2 ON", status.right);
|
||||
|
||||
ctx.link_up = false;
|
||||
composeRow(ROW_STATUS, ctx, makeEmpty(), &status);
|
||||
EXPECT_STREQ("WiFi -- R2 ON", status.right);
|
||||
}
|
||||
|
||||
TEST(ObserverDashboardFormat, EveryRowFitsTheCharacterBudget) {
|
||||
for (const Profile& p : {portrait(), landscape()}) {
|
||||
for (const RadioActivitySnapshot& s : {makeBusy(), makeEmpty()}) {
|
||||
@@ -506,6 +520,27 @@ TEST(ObserverDashboardSignature, LinkStateOnlyTouchesTheStatusRow) {
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ObserverDashboardSignature, SecondRadioStateOnlyTouchesTheStatusRow) {
|
||||
Layout l = portraitLayout();
|
||||
RadioActivitySnapshot s = makeBusy();
|
||||
|
||||
Context single = makeContext();
|
||||
Context dual = makeContext();
|
||||
dual.dual_radio = true;
|
||||
|
||||
uint32_t sa[ROW_COUNT], sb[ROW_COUNT];
|
||||
allRowSignatures(l, single, s, sa);
|
||||
allRowSignatures(l, dual, 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();
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
#include <helpers/ui/RadioProfileDisplayPage.h>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
TEST(RadioProfileDisplayPage, AlternatesEverySevenSecondsOnlyWhenEnabled) {
|
||||
using mesh::ui::RADIO_PROFILE_DISPLAY_PAGE_MILLIS;
|
||||
using mesh::ui::showSecondaryRadioProfilePage;
|
||||
|
||||
EXPECT_FALSE(showSecondaryRadioProfilePage(false, RADIO_PROFILE_DISPLAY_PAGE_MILLIS));
|
||||
EXPECT_FALSE(showSecondaryRadioProfilePage(true, 0));
|
||||
EXPECT_FALSE(showSecondaryRadioProfilePage(true, RADIO_PROFILE_DISPLAY_PAGE_MILLIS - 1));
|
||||
EXPECT_TRUE(showSecondaryRadioProfilePage(true, RADIO_PROFILE_DISPLAY_PAGE_MILLIS));
|
||||
EXPECT_TRUE(showSecondaryRadioProfilePage(true, 2 * RADIO_PROFILE_DISPLAY_PAGE_MILLIS - 1));
|
||||
EXPECT_FALSE(showSecondaryRadioProfilePage(true, 2 * RADIO_PROFILE_DISPLAY_PAGE_MILLIS));
|
||||
}
|
||||
|
||||
TEST(RadioProfileDisplayPage, LabelsTemporaryAndSavedProfilesClearly) {
|
||||
EXPECT_STREQ("R1", mesh::ui::radioProfileDisplayTag(false, false));
|
||||
EXPECT_STREQ("T1", mesh::ui::radioProfileDisplayTag(false, true));
|
||||
EXPECT_STREQ("R2", mesh::ui::radioProfileDisplayTag(true, false));
|
||||
EXPECT_STREQ("T2", mesh::ui::radioProfileDisplayTag(true, true));
|
||||
}
|
||||
|
||||
TEST(RadioProfileDisplayPage, PlacesSystemStatusAfterEachRadioProfile) {
|
||||
using mesh::ui::RADIO_PROFILE_DISPLAY_PAGE_MILLIS;
|
||||
using mesh::ui::radioProfileDisplayPageIndex;
|
||||
using mesh::ui::showRadioProfileSystemStatusPage;
|
||||
using mesh::ui::showSecondaryRadioProfilePage;
|
||||
|
||||
// Dual-radio: R1, R2, system status, then back to R1.
|
||||
EXPECT_EQ(0, radioProfileDisplayPageIndex(true, 1, 0));
|
||||
EXPECT_EQ(1, radioProfileDisplayPageIndex(true, 1,
|
||||
RADIO_PROFILE_DISPLAY_PAGE_MILLIS));
|
||||
EXPECT_EQ(2, radioProfileDisplayPageIndex(true, 1,
|
||||
2 * RADIO_PROFILE_DISPLAY_PAGE_MILLIS));
|
||||
EXPECT_TRUE(showSecondaryRadioProfilePage(true, 1,
|
||||
RADIO_PROFILE_DISPLAY_PAGE_MILLIS));
|
||||
uint8_t status_page = 99;
|
||||
EXPECT_TRUE(showRadioProfileSystemStatusPage(true, 1,
|
||||
2 * RADIO_PROFILE_DISPLAY_PAGE_MILLIS, &status_page));
|
||||
EXPECT_EQ(0, status_page);
|
||||
|
||||
// A very short display gets each status sub-page without losing either
|
||||
// radio profile from the cycle.
|
||||
EXPECT_TRUE(showRadioProfileSystemStatusPage(true, 2,
|
||||
3 * RADIO_PROFILE_DISPLAY_PAGE_MILLIS, &status_page));
|
||||
EXPECT_EQ(1, status_page);
|
||||
EXPECT_FALSE(showRadioProfileSystemStatusPage(true, 2,
|
||||
4 * RADIO_PROFILE_DISPLAY_PAGE_MILLIS, &status_page));
|
||||
}
|
||||
|
||||
TEST(RadioProfileDisplayPage, ManualPagesDoNotDependOnElapsedTime) {
|
||||
using mesh::ui::radioProfileManualPageIndex;
|
||||
using mesh::ui::showRadioProfileSystemStatusPage;
|
||||
using mesh::ui::showSecondaryRadioProfilePage;
|
||||
|
||||
// A repeater begins on R1 and only changes page when its button handler
|
||||
// advances this selected-page value.
|
||||
EXPECT_FALSE(mesh::ui::showManualSecondaryRadioProfilePage(true,
|
||||
static_cast<uint8_t>(0)));
|
||||
EXPECT_TRUE(mesh::ui::showManualSecondaryRadioProfilePage(true,
|
||||
static_cast<uint8_t>(1)));
|
||||
EXPECT_EQ(0, radioProfileManualPageIndex(true, 1, 3));
|
||||
|
||||
uint8_t status_page = 99;
|
||||
EXPECT_TRUE(mesh::ui::showManualRadioProfileSystemStatusPage(true, 1,
|
||||
static_cast<uint8_t>(2), &status_page));
|
||||
EXPECT_EQ(0, status_page);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -12,11 +12,13 @@ HARNESS = r'''
|
||||
#include <initializer_list>
|
||||
#include <RadioProfiles.h>
|
||||
#include <helpers/radiolib/RXPowerSaving.h>
|
||||
#include <helpers/radiolib/NoiseFloorEstimator.h>
|
||||
#define RADIOLIB_ERR_NONE 0
|
||||
#define STATE_IDLE 0
|
||||
#define STATE_RX 1
|
||||
#define STATE_TX_WAIT 3
|
||||
#define STATE_INT_READY 16
|
||||
#define NF_CALIB_SETTLE_MS 7UL
|
||||
#define MESH_DEBUG_PRINTLN(...) ((void)0)
|
||||
namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; }
|
||||
static uint64_t elapsed_us;
|
||||
@@ -33,7 +35,14 @@ struct RadioLibWrapper {
|
||||
bool _rx_ps_enabled=true, _rx_ps_armed=true, _rx_ps_continuous_fallback=false;
|
||||
bool _profile_saved_rxps=false, _profile_rxps_suspended=false;
|
||||
bool _profile_standby_held=false, _saved_standby_xosc=false;
|
||||
bool _nf_calib_active=false, _noise_floor_valid=true, _profile_refresh_required=false;
|
||||
bool _nf_calib_active=false, _nf_refresh_requested=false, _noise_floor_valid=true, _noise_floor_secondary_valid=true, _profile_refresh_required=false;
|
||||
unsigned long _nf_last_calib=0, _nf_calib_deadline=0, _nf_sample_from=0;
|
||||
NoiseFloorEstimator _floor_estimator;
|
||||
NoiseFloorEstimator _secondary_floor_estimator;
|
||||
static constexpr uint32_t NoiseFloorSettleMillis=7UL;
|
||||
NoiseFloorEstimator& profileFloorEstimator(uint8_t profile) {
|
||||
return profile == 1 ? _secondary_floor_estimator : _floor_estimator;
|
||||
}
|
||||
uint32_t _rx_ps_rx_us=50000, _rx_ps_sleep_us=50000;
|
||||
uint8_t _active_profile=0, _cur_sf=7, _cur_cr=5;
|
||||
uint32_t _profile_generation=1, _profile_visit_us=0, _profile_retry_at=0;
|
||||
@@ -184,6 +193,7 @@ int main() {
|
||||
}
|
||||
{
|
||||
RadioLibWrapper w; w.enable();
|
||||
w._nf_refresh_requested=false; // exercise the ordinary, post-calibration cadence
|
||||
assert(w._profile_rxps_suspended && !w._rx_ps_enabled && !w._rx_ps_armed);
|
||||
assert(w.chip.standbyXOSC && w._profile_standby_held && !w._saved_standby_xosc);
|
||||
w.setProfileStandbyWarm(true); // repeated requests must not overwrite the saved RC policy
|
||||
@@ -211,9 +221,34 @@ int main() {
|
||||
w._profiles.secondary.params.bw=62.5;
|
||||
++w._profiles.generation[1];
|
||||
w.serviceProfileScan();assert(w._active_profile==1); // slower channel first
|
||||
w._nf_refresh_requested=false; // ordinary cadence after the replacement baseline
|
||||
elapsed_us+=w._profiles.listenUs(1)-1;w.serviceProfileScan();assert(w._active_profile==1);
|
||||
elapsed_us++;w.serviceProfileScan();assert(w._active_profile==0);
|
||||
}
|
||||
{
|
||||
// SX126x profiles need at most a 7ms settle. A shorter visit is extended
|
||||
// only to that bound, never to the former 20ms calibration bound.
|
||||
RadioLibWrapper w;
|
||||
w._nf_refresh_requested=true;
|
||||
w.enable();
|
||||
const auto primary_visit=w._profiles.listenUs(0);
|
||||
const auto calibrated_visit=primary_visit < 7000 ? 7000 : primary_visit;
|
||||
elapsed_us += calibrated_visit - 1; w.serviceProfileScan(); assert(w._active_profile==0);
|
||||
elapsed_us++; w.serviceProfileScan(); assert(w._active_profile==1);
|
||||
}
|
||||
{
|
||||
// Between spaced noise samples, even a very short profile must keep its
|
||||
// normal visit. Calibration may not turn every fast scan hop into 7ms.
|
||||
RadioLibWrapper w;
|
||||
w._nf_refresh_requested=true;
|
||||
w.enable();
|
||||
w._floor_estimator.add(-100, millis());
|
||||
w._active_profile=0;
|
||||
w._profile_visit_us=micros();
|
||||
const auto primary_visit=w._profiles.listenUs(0);
|
||||
elapsed_us += primary_visit - 1; w.serviceProfileScan(); assert(w._active_profile==0);
|
||||
elapsed_us++; w.serviceProfileScan(); assert(w._active_profile==1);
|
||||
}
|
||||
{
|
||||
RadioLibWrapper w;w.enable();w.fail=true;
|
||||
elapsed_us+=100000;w.serviceProfileScan();
|
||||
|
||||
@@ -36,7 +36,8 @@ HARNESS = r'''
|
||||
#define NF_CALIB_INTERVAL_MS 2000UL
|
||||
#define NF_CALIB_TIMEOUT_MS NoiseFloorEstimator::WINDOW_TIMEOUT_MS
|
||||
#define NF_CONTINUOUS_TIMEOUT_MS NoiseFloorEstimator::WINDOW_TIMEOUT_MS
|
||||
#define NF_CALIB_SETTLE_MS 20UL
|
||||
#define NF_CALIB_SETTLE_MS 7UL
|
||||
#define NF_FAST_PROFILE_REFRESH_INTERVAL_MS (15UL * 60UL * 1000UL)
|
||||
static volatile uint8_t state = STATE_RX;
|
||||
static uint32_t now_ms = 0;
|
||||
uint32_t millis() { return now_ms; }
|
||||
@@ -62,11 +63,15 @@ struct RadioLibWrapper {
|
||||
bool serviceCarrierWave() { return false; } // dedicated CW harness owns this path
|
||||
mesh::RadioProfiles _profiles;
|
||||
uint32_t _profile_visit_us = 0;
|
||||
uint32_t _profile_scan_generation[2] = {};
|
||||
uint8_t _active_profile = 0;
|
||||
bool _profile_rxps_suspended = false;
|
||||
void serviceProfileScan() {} // separate profile-scan harness exercises tuning
|
||||
Board board; Board* _board = &board;
|
||||
Radio radio; Radio* _radio = &radio;
|
||||
bool _rx_ps_enabled = false, _rx_ps_armed = false, _rx_ps_continuous_fallback = false;
|
||||
bool _nf_calib_active = false, _nf_refresh_requested = true, _noise_floor_valid = true;
|
||||
bool _nf_calib_active = false, _nf_refresh_requested = true, _noise_floor_valid = true,
|
||||
_noise_floor_secondary_valid = true;
|
||||
bool _cad_enabled = true, packet = false, busy = false, inject_arm_irq = false;
|
||||
bool _rx_boosted_gain_valid = false, _cur_rx_boosted_gain = false;
|
||||
bool _wd_last_busy = false;
|
||||
@@ -75,12 +80,17 @@ struct RadioLibWrapper {
|
||||
unsigned long _nf_last_calib = 0, _nf_calib_deadline = 0, _nf_sample_from = 0;
|
||||
unsigned long _wd_last_transition = 0, _wd_stuck_thresh = 0, _wd_observe_ms = 0;
|
||||
int16_t _threshold = 0, _noise_floor = -105;
|
||||
int32_t _noise_floor_centi_dbm = -10500;
|
||||
int32_t _noise_floor_centi_dbm = -10500, _noise_floor_secondary_centi_dbm = -10600;
|
||||
int arm_result = 0, chip_mode = 1;
|
||||
float rssi = -100;
|
||||
unsigned arms = 0, stops = 0, soft = 0, hard = 0, reads = 0, mode_reads = 0;
|
||||
bool inject_mode_irq = false;
|
||||
NoiseFloorEstimator _floor_estimator;
|
||||
NoiseFloorEstimator _secondary_floor_estimator;
|
||||
NoiseFloorEstimator& profileFloorEstimator(uint8_t profile) {
|
||||
return profile == 1 ? _secondary_floor_estimator : _floor_estimator;
|
||||
}
|
||||
uint16_t profilePreamble(uint8_t profile) const { return _profiles.preamble(profile, 32); }
|
||||
RadioLibWrapper() { state = STATE_RX; now_ms = 100; }
|
||||
bool isChipBusy() { return busy; }
|
||||
bool isReceivingPacket() { return packet; }
|
||||
@@ -109,6 +119,7 @@ struct RadioLibWrapper {
|
||||
void startRecv();
|
||||
void checkReceiveMode(uint32_t);
|
||||
void loop();
|
||||
void triggerNoiseFloorCalibrate(int);
|
||||
void requestRestartRecv();
|
||||
void noiseFloorCalibCheck(unsigned long);
|
||||
void endNoiseFloorCalib(unsigned long);
|
||||
@@ -180,13 +191,13 @@ int main() {
|
||||
{
|
||||
RadioLibWrapper w;
|
||||
w.startRecv();
|
||||
for (; now_ms < 120; ++now_ms) w.loop();
|
||||
for (; now_ms < 107; ++now_ms) w.loop();
|
||||
assert(w.reads == 0);
|
||||
w.loop();
|
||||
assert(w.reads == 1);
|
||||
now_ms = 200;
|
||||
w.startRecv(); // CAD/TX re-arm must settle again, retaining earlier samples
|
||||
for (; now_ms < 220; ++now_ms) w.loop();
|
||||
for (; now_ms < 207; ++now_ms) w.loop();
|
||||
assert(w.reads == 1 && w._floor_estimator.count() == 1);
|
||||
w.loop();
|
||||
assert(w.reads == 2 && w._floor_estimator.count() == 2);
|
||||
@@ -200,6 +211,80 @@ int main() {
|
||||
}
|
||||
assert(w._noise_floor_centi_dbm == -10125);
|
||||
}
|
||||
// After dual-profile initialization, the Dispatcher's two-second
|
||||
// maintenance tick updates the threshold but must not restart calibration.
|
||||
// A profile whose normal visit is under 7 ms gets one new bounded 7 ms
|
||||
// sample set after fifteen minutes; a naturally long profile does not.
|
||||
{
|
||||
RadioLibWrapper w;
|
||||
mesh::RadioProfileConfig second;
|
||||
second.params.freq = 910.5; second.params.bw = 62.5; second.params.sf = 7; second.params.cr = 5;
|
||||
second.mode = mesh::RadioProfileMode::RxTx;
|
||||
w._profiles.primary = second.params;
|
||||
w._profiles.setSecondary(second, false);
|
||||
w._nf_refresh_requested = false;
|
||||
w._noise_floor_valid = w._noise_floor_secondary_valid = true;
|
||||
w.triggerNoiseFloorCalibrate(7);
|
||||
assert(!w._nf_refresh_requested && w._threshold == 7);
|
||||
w._noise_floor_secondary_valid = false;
|
||||
w.triggerNoiseFloorCalibrate(8);
|
||||
assert(w._nf_refresh_requested && w._threshold == 8);
|
||||
}
|
||||
{
|
||||
RadioLibWrapper w;
|
||||
mesh::RadioProfileConfig second;
|
||||
second.params.freq = 910.5; second.params.bw = 500; second.params.sf = 5; second.params.cr = 5;
|
||||
second.mode = mesh::RadioProfileMode::Rx;
|
||||
w._profiles.primary = second.params;
|
||||
w._profiles.setSecondary(second, false);
|
||||
w._nf_refresh_requested = false;
|
||||
w._noise_floor_valid = w._noise_floor_secondary_valid = true;
|
||||
w._nf_last_calib = now_ms;
|
||||
now_ms += NF_FAST_PROFILE_REFRESH_INTERVAL_MS - 1;
|
||||
w.triggerNoiseFloorCalibrate(7);
|
||||
assert(!w._nf_refresh_requested);
|
||||
++now_ms;
|
||||
w.triggerNoiseFloorCalibrate(7);
|
||||
assert(w._nf_refresh_requested);
|
||||
}
|
||||
{
|
||||
RadioLibWrapper w;
|
||||
mesh::RadioProfileConfig second;
|
||||
second.params.freq = 910.5; second.params.bw = 62.5; second.params.sf = 7; second.params.cr = 5;
|
||||
second.mode = mesh::RadioProfileMode::Rx;
|
||||
w._profiles.primary = second.params;
|
||||
w._profiles.setSecondary(second, false);
|
||||
w._nf_refresh_requested = false;
|
||||
w._noise_floor_valid = w._noise_floor_secondary_valid = true;
|
||||
w._nf_last_calib = now_ms;
|
||||
now_ms += NF_FAST_PROFILE_REFRESH_INTERVAL_MS;
|
||||
w.triggerNoiseFloorCalibrate(7);
|
||||
assert(!w._nf_refresh_requested);
|
||||
}
|
||||
now_ms = 100; // subsequent timing tests use their original short timeline
|
||||
// R1 and R2 collect distinct RSSI populations across alternating visits;
|
||||
// publishing one must never blend the other channel into its floor.
|
||||
{
|
||||
RadioLibWrapper w;
|
||||
mesh::RadioProfileConfig second;
|
||||
second.params.freq = 910.5; second.params.bw = 62.5; second.params.sf = 7; second.params.cr = 5;
|
||||
second.mode = mesh::RadioProfileMode::RxTx;
|
||||
w._profiles.primary = second.params;
|
||||
w._profiles.setSecondary(second, false);
|
||||
w._profile_scan_generation[0] = w._profiles.generation[0];
|
||||
w._profile_scan_generation[1] = w._profiles.generation[1];
|
||||
w._profile_rxps_suspended = true;
|
||||
w._noise_floor_valid = w._noise_floor_secondary_valid = false;
|
||||
w._nf_refresh_requested = true;
|
||||
w._nf_sample_from = 0;
|
||||
for (unsigned i = 0; i < NoiseFloorEstimator::SAMPLE_COUNT; ++i) {
|
||||
w._active_profile = 0; w.rssi = -100; w.loop(); now_ms += 50;
|
||||
w._active_profile = 1; w.rssi = -90; w.loop(); now_ms += 50;
|
||||
}
|
||||
w.loop(); // publish after both profile estimators complete
|
||||
assert(!w._nf_refresh_requested && w._noise_floor_centi_dbm == -10000);
|
||||
assert(w._noise_floor_secondary_centi_dbm == -9000);
|
||||
}
|
||||
// Hardware standby RSSI is not background noise, even if software says RX.
|
||||
{
|
||||
RadioLibWrapper w;
|
||||
@@ -505,7 +590,7 @@ int main() {
|
||||
source = (ROOT / 'src/helpers/radiolib/RadioLibWrappers.cpp').read_text()
|
||||
names = [('int16_t','performChannelScanWithTimeout'), ('bool','isPacketPendingOrReceiving'),
|
||||
('bool','isChannelActive'), ('void','startRecv'), ('void','checkReceiveMode'),
|
||||
('void','loop'), ('void','requestRestartRecv'), ('void','noiseFloorCalibCheck'),
|
||||
('void','loop'), ('void','triggerNoiseFloorCalibrate'), ('void','requestRestartRecv'), ('void','noiseFloorCalibCheck'),
|
||||
('void','endNoiseFloorCalib'), ('void','requestNoiseFloorRefresh'),
|
||||
('void','recalibrateNoiseFloor'), ('void','resetAGC')]
|
||||
methods = '\n'.join(method(source, f'{kind} RadioLibWrapper::{name}(') for kind,name in names)
|
||||
|
||||
Reference in new issue
Block a user