feat(ui): page radio and system status

This commit is contained in:
mikecarper committed 2026-09-21 15:35:46 -07:00
1 parent 527bc98da2
commit a95f7b3d16
28 files changed
+2075 -73

No files matched your search

+123 -6
View File
@@ -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);
}
+128 -4
View File
@@ -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);
+131 -8
View File
@@ -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);
}
+511 -18
View File
@@ -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;
}
}
+9
View File
@@ -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;
+159 -6
View File
@@ -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
+4
View File
@@ -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:
+4
View File
@@ -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();
+118 -5
View File
@@ -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
}
+5 -1
View File
@@ -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();
};
};
+4
View File
@@ -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) ;
}
+16
View File
@@ -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) { }
+8 -1
View File
@@ -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
+2 -2
View File
@@ -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);
+127 -12
View File
@@ -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();
}
+56 -1
View File
@@ -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));
+10
View File
@@ -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))
+16 -2
View File
@@ -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);
+93
View File
@@ -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
+252
View File
@@ -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
View File
@@ -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();
}
+36 -1
View File
@@ -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();
+91 -6
View File
@@ -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)