From a95f7b3d162b1cbc37cbc6cea17e1e8880b59c5b Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 21 Sep 2026 15:35:46 -0700 Subject: [PATCH] feat(ui): page radio and system status --- examples/companion_radio/ui-new/UITask.cpp | 129 ++++- examples/companion_radio/ui-orig/UITask.cpp | 132 ++++- examples/companion_radio/ui-orig/UITask.h | 6 + examples/companion_radio/ui-tiny/UITask.cpp | 139 ++++- examples/simple_repeater/UITask.cpp | 529 +++++++++++++++++- examples/simple_repeater/UITask.h | 9 + examples/simple_room_server/UITask.cpp | 165 +++++- examples/simple_room_server/UITask.h | 4 + examples/simple_sensor/SensorMesh.h | 4 + examples/simple_sensor/UITask.cpp | 123 +++- examples/simple_sensor/UITask.h | 6 +- examples/simple_sensor/main.cpp | 4 + src/Dispatcher.h | 16 + src/helpers/CommonCLI.cpp | 9 +- src/helpers/CommonCLI.h | 4 +- src/helpers/radiolib/NoiseFloorEstimator.h | 7 + src/helpers/radiolib/RadioLibWrappers.cpp | 139 ++++- src/helpers/radiolib/RadioLibWrappers.h | 57 +- src/helpers/ui/CompanionHomeLayout.h | 10 + src/helpers/ui/ObserverDashboard.h | 18 +- src/helpers/ui/RadioProfileDisplayPage.h | 93 +++ src/helpers/ui/RadioProfileSystemStatus.h | 252 +++++++++ test/fixtures/st7735_native/render.cpp | 38 ++ .../test_noise_floor_estimator.cpp | 12 + .../test_observer_dashboard.cpp | 35 ++ .../test_radio_profile_display_page.cpp | 74 +++ test/test_radio_profile_scan.py | 37 +- test/test_radio_receive_contract.py | 97 +++- 28 files changed, 2075 insertions(+), 73 deletions(-) create mode 100644 src/helpers/ui/RadioProfileDisplayPage.h create mode 100644 src/helpers/ui/RadioProfileSystemStatus.h create mode 100644 test/test_radio_profile_display_page/test_radio_profile_display_page.cpp diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index eeb83ed2..9f44ae15 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -12,6 +12,9 @@ #include #endif #include +#include +#include +#include #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(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); } diff --git a/examples/companion_radio/ui-orig/UITask.cpp b/examples/companion_radio/ui-orig/UITask.cpp index a212de55..c47f6920 100644 --- a/examples/companion_radio/ui-orig/UITask.cpp +++ b/examples/companion_radio/ui-orig/UITask.cpp @@ -5,6 +5,9 @@ #include #include #include +#include +#include +#include #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(); } } diff --git a/examples/companion_radio/ui-orig/UITask.h b/examples/companion_radio/ui-orig/UITask.h index 4697a521..7d253679 100644 --- a/examples/companion_radio/ui-orig/UITask.h +++ b/examples/companion_radio/ui-orig/UITask.h @@ -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); diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index d2b6df5f..fa8e2b44 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -4,7 +4,10 @@ #endif #include #include +#include +#include #include "../MyMesh.h" +#include #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(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); } diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index 2f571ac3..1c56e170 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -5,6 +5,261 @@ #include #include #include +#include +#include +#include +#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 @@ -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; } } diff --git a/examples/simple_repeater/UITask.h b/examples/simple_repeater/UITask.h index 0526d57d..4c965f9b 100644 --- a/examples/simple_repeater/UITask.h +++ b/examples/simple_repeater/UITask.h @@ -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; diff --git a/examples/simple_room_server/UITask.cpp b/examples/simple_room_server/UITask.cpp index 23e62a58..8efe9696 100644 --- a/examples/simple_room_server/UITask.cpp +++ b/examples/simple_room_server/UITask.cpp @@ -4,6 +4,59 @@ #include #include #include +#include +#include +#include +#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 @@ -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 diff --git a/examples/simple_room_server/UITask.h b/examples/simple_room_server/UITask.h index 0515ae1e..a407ec2b 100644 --- a/examples/simple_room_server/UITask.h +++ b/examples/simple_room_server/UITask.h @@ -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: diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index f60a3a7a..112e2952 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -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(); diff --git a/examples/simple_sensor/UITask.cpp b/examples/simple_sensor/UITask.cpp index d77c00e2..43f2dacd 100644 --- a/examples/simple_sensor/UITask.cpp +++ b/examples/simple_sensor/UITask.cpp @@ -2,6 +2,57 @@ #include "target.h" #include #include +#include +#include +#include +#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 } diff --git a/examples/simple_sensor/UITask.h b/examples/simple_sensor/UITask.h index b027fd6d..bb909c7e 100644 --- a/examples/simple_sensor/UITask.h +++ b/examples/simple_sensor/UITask.h @@ -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(); -}; \ No newline at end of file +}; diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index 19253b13..dc9b289a 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -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) ; } diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 4c1a513d..cc114d81 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -134,6 +134,22 @@ public: virtual float getNoiseFloorDbm() const { return static_cast(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) { } diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 39dcd915..b4dd951f 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -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 diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index bfe26f3a..0c542a7a 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -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; diff --git a/src/helpers/radiolib/NoiseFloorEstimator.h b/src/helpers/radiolib/NoiseFloorEstimator.h index eec30d14..10794a39 100644 --- a/src/helpers/radiolib/NoiseFloorEstimator.h +++ b/src/helpers/radiolib/NoiseFloorEstimator.h @@ -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); diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 30810c3d..5d493e9b 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -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(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(millis())); + const unsigned long now = millis(); + checkReceiveMode(static_cast(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(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(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(); } diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 54d230d0..1e4670c7 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -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)); diff --git a/src/helpers/ui/CompanionHomeLayout.h b/src/helpers/ui/CompanionHomeLayout.h index e7586172..f873570a 100644 --- a/src/helpers/ui/CompanionHomeLayout.h +++ b/src/helpers/ui/CompanionHomeLayout.h @@ -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)) diff --git a/src/helpers/ui/ObserverDashboard.h b/src/helpers/ui/ObserverDashboard.h index 04e64b41..ec4aad7c 100644 --- a/src/helpers/ui/ObserverDashboard.h +++ b/src/helpers/ui/ObserverDashboard.h @@ -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); diff --git a/src/helpers/ui/RadioProfileDisplayPage.h b/src/helpers/ui/RadioProfileDisplayPage.h new file mode 100644 index 00000000..6f02520a --- /dev/null +++ b/src/helpers/ui/RadioProfileDisplayPage.h @@ -0,0 +1,93 @@ +#pragma once + +#include + +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 diff --git a/src/helpers/ui/RadioProfileSystemStatus.h b/src/helpers/ui/RadioProfileSystemStatus.h new file mode 100644 index 00000000..425ac1ad --- /dev/null +++ b/src/helpers/ui/RadioProfileSystemStatus.h @@ -0,0 +1,252 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +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 diff --git a/test/fixtures/st7735_native/render.cpp b/test/fixtures/st7735_native/render.cpp index 88724d5c..9e680f75 100644 --- a/test/fixtures/st7735_native/render.cpp +++ b/test/fixtures/st7735_native/render.cpp @@ -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. diff --git a/test/test_noise_floor_estimator/test_noise_floor_estimator.cpp b/test/test_noise_floor_estimator/test_noise_floor_estimator.cpp index dab9f437..992c8597 100644 --- a/test/test_noise_floor_estimator/test_noise_floor_estimator.cpp +++ b/test/test_noise_floor_estimator/test_noise_floor_estimator.cpp @@ -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; diff --git a/test/test_observer_dashboard/test_observer_dashboard.cpp b/test/test_observer_dashboard/test_observer_dashboard.cpp index c74e981d..57ac9367 100644 --- a/test/test_observer_dashboard/test_observer_dashboard.cpp +++ b/test/test_observer_dashboard/test_observer_dashboard.cpp @@ -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(); diff --git a/test/test_radio_profile_display_page/test_radio_profile_display_page.cpp b/test/test_radio_profile_display_page/test_radio_profile_display_page.cpp new file mode 100644 index 00000000..9a1781e6 --- /dev/null +++ b/test/test_radio_profile_display_page/test_radio_profile_display_page.cpp @@ -0,0 +1,74 @@ +#include + +#include + +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(0))); + EXPECT_TRUE(mesh::ui::showManualSecondaryRadioProfilePage(true, + static_cast(1))); + EXPECT_EQ(0, radioProfileManualPageIndex(true, 1, 3)); + + uint8_t status_page = 99; + EXPECT_TRUE(mesh::ui::showManualRadioProfileSystemStatusPage(true, 1, + static_cast(2), &status_page)); + EXPECT_EQ(0, status_page); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_radio_profile_scan.py b/test/test_radio_profile_scan.py index 3d5bb0c5..31b8d68a 100644 --- a/test/test_radio_profile_scan.py +++ b/test/test_radio_profile_scan.py @@ -12,11 +12,13 @@ HARNESS = r''' #include #include #include +#include #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(); diff --git a/test/test_radio_receive_contract.py b/test/test_radio_receive_contract.py index c6590bb4..54eba5fa 100644 --- a/test/test_radio_receive_contract.py +++ b/test/test_radio_receive_contract.py @@ -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)