From 76567a3afbc578996d22174e0903946e011e246e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 10:49:01 +0200 Subject: [PATCH 01/44] fix(sensors): no default GPS reset pin on RAK WisBlock gpsResetPin defaulted to 0, which is a real GPIO on nRF52 (P0.00, the 32 kHz LFXO crystal). -1 makes pinMode/digitalWrite no-ops until a GPS enable pin is actually found. --- src/helpers/sensors/EnvironmentSensorManager.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index 0dc892c3a52..595668c0926 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -165,7 +165,9 @@ static RAK12035_SoilMoisture RAK12035; #endif #ifdef RAK_WISBLOCK_GPS -static uint32_t gpsResetPin = 0; +// -1 = no enable pin; out-of-range values are no-ops in pinMode/digitalWrite, +// while 0 would be a real GPIO (P0.00 = LFXO crystal on nRF52) +static uint32_t gpsResetPin = -1; static bool i2cGPSFlag = false; static bool serialGPSFlag = false; #ifndef TELEM_RAK12500_ADDRESS From 60160f4277eff03c5f34c271b2987b6dbb2e3580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 10:55:08 +0200 Subject: [PATCH 02/44] fix(cli): 'gps' command crash when no GPS setting exists getSettingByKey("gps") returns NULL on sensor managers without a gps setting, and strcmp(NULL, "1") crashed. From PowerSaving-v17 e3d7feb5. --- src/helpers/CommonCLI.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 04199e24d1d..961e995531c 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -423,7 +423,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re bool enabled = l->isEnabled(); // is EN pin on ? bool fix = l->isValid(); // has fix ? int sats = l->satellitesCount(); - bool active = !strcmp(_sensors->getSettingByKey("gps"), "1"); + const char* gps_setting = _sensors->getSettingByKey("gps"); // NULL when the sensor manager has no "gps" setting + bool active = gps_setting != NULL && strcmp(gps_setting, "1") == 0; if (enabled) { sprintf(reply, "on, %s, %s, %d sats", active?"active":"deactivated", From b50e6be13843520bf5a47302cb05ed7b88c8efb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 11:00:38 +0200 Subject: [PATCH 03/44] variants(xiao_s3_wio): enable battery voltage reading PIN_VBAT_READ=1 (D0) as in the PowerSaving-v17 build. From PowerSaving-v17 43882d86. --- variants/xiao_s3_wio/platformio.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/variants/xiao_s3_wio/platformio.ini b/variants/xiao_s3_wio/platformio.ini index 5a2ef106173..6c5e5bf8572 100644 --- a/variants/xiao_s3_wio/platformio.ini +++ b/variants/xiao_s3_wio/platformio.ini @@ -8,6 +8,7 @@ build_flags = ${esp32_base.build_flags} -I variants/xiao_s3_wio -UENV_INCLUDE_GPS -D SEEED_XIAO_S3 + -D PIN_VBAT_READ=1 ; D0, as in PowerSaving-v17 -D P_LORA_DIO_1=39 -D P_LORA_NSS=41 -D P_LORA_RESET=42 ; RADIOLIB_NC From a7f8d2616edfc96b814894d70df180d9f8e993b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 11:06:30 +0200 Subject: [PATCH 04/44] variants(techo_lite): keep the blue LED off in BLE builds The Bluefruit library blinks LED_BLUE while advertising. DISABLE_BLE_LED keeps it off; the upstream LED init (HIGH = off) stays, so the active-LOW pin is never left floating. From PowerSaving-v17 e7bd0a89 (same goal, different mechanism). --- variants/lilygo_techo_lite/platformio.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/variants/lilygo_techo_lite/platformio.ini b/variants/lilygo_techo_lite/platformio.ini index 2a92eb9be2d..e8a9b5f2358 100644 --- a/variants/lilygo_techo_lite/platformio.ini +++ b/variants/lilygo_techo_lite/platformio.ini @@ -83,6 +83,7 @@ build_flags = -D MAX_GROUP_CHANNELS=40 ; -D QSPIFLASH=1 -D BLE_PIN_CODE=123456 + -D DISABLE_BLE_LED ; keep the blue LED off instead of blinking while advertising ; -D BLE_DEBUG_LOGGING=1 -D OFFLINE_QUEUE_SIZE=256 -D UI_RECENT_LIST_SIZE=9 @@ -154,6 +155,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D BLE_PIN_CODE=123456 + -D DISABLE_BLE_LED ; keep the blue LED off instead of blinking while advertising -D OFFLINE_QUEUE_SIZE=256 -D UI_RECENT_LIST_SIZE=9 -D AUTO_SHUTDOWN_MILLIVOLTS=3300 From 3a72dd9bd68b8b6169e2bbb108d4c4f0cf4d3c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 11:13:16 +0200 Subject: [PATCH 05/44] variants(thinknode_m6, rak4631): no BLE_DEBUG_LOGGING in companion BLE builds From PowerSaving-v17 8002d1e2. --- variants/rak4631/platformio.ini | 2 +- variants/thinknode_m6/platformio.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index d2781d9fa2b..4c32a47865f 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -219,7 +219,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D BLE_PIN_CODE=123456 - -D BLE_DEBUG_LOGGING=1 +; -D BLE_DEBUG_LOGGING=1 -D OFFLINE_QUEUE_SIZE=256 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 diff --git a/variants/thinknode_m6/platformio.ini b/variants/thinknode_m6/platformio.ini index ad7e6902cf0..4ed0848d1ae 100644 --- a/variants/thinknode_m6/platformio.ini +++ b/variants/thinknode_m6/platformio.ini @@ -82,7 +82,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D BLE_PIN_CODE=123456 - -D BLE_DEBUG_LOGGING=1 +; -D BLE_DEBUG_LOGGING=1 -D OFFLINE_QUEUE_SIZE=256 -D AUTO_SHUTDOWN_MILLIVOLTS=3300 ; -D MESH_PACKET_LOGGING=1 From 5da2f7c7036df6b1ce9d78a0a43342bed2ac3ece Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 11:19:08 +0200 Subject: [PATCH 06/44] variants(wio_wm1110): upload with nrfutil instead of J-Link From PowerSaving-v17 22fdd862. --- variants/wio_wm1110/platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/wio_wm1110/platformio.ini b/variants/wio_wm1110/platformio.ini index a7eac916b08..18c052296cf 100644 --- a/variants/wio_wm1110/platformio.ini +++ b/variants/wio_wm1110/platformio.ini @@ -30,7 +30,7 @@ build_src_filter = ${nrf52_base.build_src_filter} + +<../variants/wio_wm1110> debug_tool = jlink -upload_protocol = jlink +upload_protocol = nrfutil lib_deps = ${nrf52_base.lib_deps} ${sensor_base.lib_deps} adafruit/Adafruit LIS3DH @ ^1.2.4 From bd3ba429bdc12189949be25c9152f652f8c625a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 11:25:29 +0200 Subject: [PATCH 07/44] variants(wio-tracker-l1): read environment sensors on Wire1 Repeater and room server builds point ENV_PIN_SDA/SCL at the Wire1 bus. From PowerSaving-v17 ba1a2ff5. --- variants/wio-tracker-l1/platformio.ini | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/variants/wio-tracker-l1/platformio.ini b/variants/wio-tracker-l1/platformio.ini index 19139c289eb..6c39ae6d311 100644 --- a/variants/wio-tracker-l1/platformio.ini +++ b/variants/wio-tracker-l1/platformio.ini @@ -36,6 +36,8 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D DISPLAY_CLASS=SH1106Display + -D ENV_PIN_SDA=PIN_WIRE1_SDA + -D ENV_PIN_SCL=PIN_WIRE1_SCL ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 lib_deps = ${WioTrackerL1.lib_deps} @@ -50,6 +52,8 @@ build_flags = ${WioTrackerL1.build_flags} -D ADMIN_PASSWORD='"password"' -D ROOM_PASSWORD='"hello"' -D DISPLAY_CLASS=SH1106Display + -D ENV_PIN_SDA=PIN_WIRE1_SDA + -D ENV_PIN_SCL=PIN_WIRE1_SCL ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 lib_deps = ${WioTrackerL1.lib_deps} From e36521847ed285b90661c744980f94a95cc197b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 11:30:59 +0200 Subject: [PATCH 08/44] variants(heltec_v4): set ARDUINO_USB_MODE=1 in build flags ARDUINO_USB_MODE in heltec_v4.json has no effect, so the flag is set in build_flags. The KISS modem env still unsets it. From PowerSaving-v17 1a7f70e7. --- variants/heltec_v4/platformio.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini index 76e8e714e68..2771b69be16 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -7,6 +7,7 @@ build_flags = -I variants/heltec_v4 -D HELTEC_LORA_V4 -D USE_SX1262 + -D ARDUINO_USB_MODE=1 ; ARDUINO_USB_MODE in heltec_v4.json has no effect -D ESP32_CPU_FREQ=80 -D RADIO_CLASS=CustomSX1262 -D WRAPPER_CLASS=CustomSX1262Wrapper From 4dc1973863c89aab195a7a96d6b5c69d77c8cc22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 11:37:32 +0200 Subject: [PATCH 09/44] nrf52: disable CC310 hardware crypto USE_CC310_HW_CRYPTO (upstream PR #2824) stays off until its benefit is proven: no measurable current difference on RAK4631 with PowerSaving, and the per-call nRFCrypto begin()/end() was questioned in review. Re-enable by uncommenting the flag. From PowerSaving-v17 d302651e. --- platformio.ini | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index f296f838213..36c07ec6ac5 100644 --- a/platformio.ini +++ b/platformio.ini @@ -92,7 +92,10 @@ build_flags = ${arduino_base.build_flags} -D NRF52_PLATFORM -D LFS_NO_ASSERT=1 -D EXTRAFS=1 - -D USE_CC310_HW_CRYPTO=1 +; CC310 hardware crypto (upstream PR #2824) disabled until its benefit is proven: +; no measurable current difference on RAK4631 with PowerSaving, and per-call +; nRFCrypto begin()/end() was questioned in review. Re-enable by uncommenting. +; -D USE_CC310_HW_CRYPTO=1 lib_deps = ${arduino_base.lib_deps} https://github.com/oltaco/CustomLFS#0.2.3 From 1e162796e67253e21ce14eab3b287236dd6b3598 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 11:47:53 +0200 Subject: [PATCH 10/44] variants(heltec_v4): add expansion-kit TFT companion BLE env heltec_v4_expansionkit_tft_companion_radio_ble is the TFT companion with environment sensors on SDA 4 / SCL 3 of the expansion kit. From PowerSaving-v17 0e47bdf8 and 320ce563. --- variants/heltec_v4/platformio.ini | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini index 2771b69be16..11897c5aa4f 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -393,6 +393,13 @@ lib_deps = ${heltec_v4_tft.lib_deps} densaugeo/base64 @ ~1.4.0 +[env:heltec_v4_expansionkit_tft_companion_radio_ble] +extends = env:heltec_v4_tft_companion_radio_ble +build_flags = + ${env:heltec_v4_tft_companion_radio_ble.build_flags} + -D ENV_PIN_SDA=4 + -D ENV_PIN_SCL=3 + [env:heltec_v4_tft_companion_radio_wifi] extends = heltec_v4_tft build_flags = From fb4a26b025ed1a4bb4e0ee32d9982aaa8c6db79a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 12:09:03 +0200 Subject: [PATCH 11/44] variants: add sensor and RS232 bridge envs from PowerSaving-v17 - Heltec_t114_sensor - t1000e_sensor - heltec_tracker_v2_repeater_bridge_rs232 - RAK_3401_repeater_bridge_rs232_serial1 - Xiao_nrf52_repeater_bridge_rs232 From PowerSaving-v17 1ce07cd8, 54a79f4a, b7cc0e6d and 41c63df0. --- variants/heltec_t114/platformio.ini | 15 +++++++++++++++ variants/heltec_tracker_v2/platformio.ini | 23 +++++++++++++++++++++++ variants/rak3401/platformio.ini | 11 +++++++++++ variants/t1000-e/platformio.ini | 17 +++++++++++++++++ variants/xiao_nrf52/platformio.ini | 18 ++++++++++++++++++ 5 files changed, 84 insertions(+) diff --git a/variants/heltec_t114/platformio.ini b/variants/heltec_t114/platformio.ini index e795bc7fa5c..74a512a6725 100644 --- a/variants/heltec_t114/platformio.ini +++ b/variants/heltec_t114/platformio.ini @@ -255,3 +255,18 @@ lib_deps = extends = Heltec_t114 build_src_filter = ${Heltec_t114.build_src_filter} +<../examples/kiss_modem/> + +[env:Heltec_t114_sensor] +extends = Heltec_t114_with_display +build_src_filter = ${Heltec_t114_with_display.build_src_filter} + +<../examples/simple_sensor> +build_flags = + ${Heltec_t114_with_display.build_flags} + -D ADVERT_NAME='"Heltec_T114 Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +; -D POWERSAVING_DEBUG=1 diff --git a/variants/heltec_tracker_v2/platformio.ini b/variants/heltec_tracker_v2/platformio.ini index 5fd9edabe7a..a0c48cd8fd1 100644 --- a/variants/heltec_tracker_v2/platformio.ini +++ b/variants/heltec_tracker_v2/platformio.ini @@ -225,3 +225,26 @@ lib_deps = extends = Heltec_tracker_v2 build_src_filter = ${Heltec_tracker_v2.build_src_filter} +<../examples/kiss_modem/> + +[env:heltec_tracker_v2_repeater_bridge_rs232] +extends = Heltec_tracker_v2 +build_flags = + ${Heltec_tracker_v2.build_flags} + -D ADVERT_NAME='"RS232 Bridge"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_RS232_BRIDGE=Serial2 + -D WITH_RS232_BRIDGE_RX=44 + -D WITH_RS232_BRIDGE_TX=43 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Heltec_tracker_v2.build_src_filter} + + + + + +<../examples/simple_repeater> +lib_deps = + ${Heltec_tracker_v2.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 diff --git a/variants/rak3401/platformio.ini b/variants/rak3401/platformio.ini index d157e4b1d3f..ead8e84bee9 100644 --- a/variants/rak3401/platformio.ini +++ b/variants/rak3401/platformio.ini @@ -134,3 +134,14 @@ build_src_filter = ${rak3401.build_src_filter} extends = rak3401 build_src_filter = ${rak3401.build_src_filter} +<../examples/kiss_modem/> + +[env:RAK_3401_repeater_bridge_rs232_serial1] +extends = env:RAK_3401_repeater +build_flags = + ${env:RAK_3401_repeater.build_flags} + -D ADVERT_NAME='"RAK3401 1W RS232 Bridge"' + -D WITH_RS232_BRIDGE=Serial1 + -D WITH_RS232_BRIDGE_RX=PIN_SERIAL1_RX + -D WITH_RS232_BRIDGE_TX=PIN_SERIAL1_TX +build_src_filter = ${env:RAK_3401_repeater.build_src_filter} + + diff --git a/variants/t1000-e/platformio.ini b/variants/t1000-e/platformio.ini index 8456dc91513..42dff1aae07 100644 --- a/variants/t1000-e/platformio.ini +++ b/variants/t1000-e/platformio.ini @@ -128,3 +128,20 @@ build_src_filter = ${t1000-e.build_src_filter} +<../examples/kiss_modem/> lib_deps = ${t1000-e.lib_deps} stevemarple/MicroNMEA @ ^2.0.6 + +[env:t1000e_sensor] +extends = t1000-e +build_flags = ${t1000-e.build_flags} + -I examples/companion_radio/ui-orig + -D ADVERT_NAME='"t1000-e Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +; -D POWERSAVING_DEBUG=1 +build_src_filter = ${t1000-e.build_src_filter} + +<../examples/simple_sensor> +lib_deps = ${t1000-e.lib_deps} + stevemarple/MicroNMEA @ ^2.0.6 diff --git a/variants/xiao_nrf52/platformio.ini b/variants/xiao_nrf52/platformio.ini index b40ebbc3600..781304d28fb 100644 --- a/variants/xiao_nrf52/platformio.ini +++ b/variants/xiao_nrf52/platformio.ini @@ -118,3 +118,21 @@ build_src_filter = ${Xiao_nrf52.build_src_filter} +<../examples/kiss_modem/*.cpp> lib_deps = ${Xiao_nrf52.lib_deps} + +[env:Xiao_nrf52_repeater_bridge_rs232] +extends = Xiao_nrf52 +build_flags = + ${Xiao_nrf52.build_flags} + -D ADVERT_NAME='"RS232 Bridge"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_RS232_BRIDGE=Serial1 + -D WITH_RS232_BRIDGE_RX=PIN_SERIAL1_RX + -D WITH_RS232_BRIDGE_TX=PIN_SERIAL1_TX +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Xiao_nrf52.build_src_filter} + + + +<../examples/simple_repeater> From 562be28150e46222376a1018136c30036c601f6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 12:19:24 +0200 Subject: [PATCH 12/44] repeater(ui): show battery voltage and PowerSaving state Two more lines on the radio page of the repeater display: battery voltage and whether powersaving is on. From PowerSaving-v17 4cb67d90. --- examples/simple_repeater/UITask.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index e0b6a41df5f..f87c1a3c6ef 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -106,6 +106,13 @@ void UITask::renderCurrScreen() { _display->setCursor(0, 30); sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); _display->print(tmp); + + _display->setCursor(0, 40); + const unsigned mv = _board->getBattMilliVolts(); + snprintf(tmp, sizeof(tmp), "BAT: %u.%03u V", mv / 1000, mv % 1000); + _display->print(tmp); + _display->setCursor(0, 50); + _display->print(_node_prefs->powersaving_enabled ? "PowerSaving: ON" : "PowerSaving: off"); } } From c091b6c3599247e523519641b983d6494ecfb0be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 12:37:10 +0200 Subject: [PATCH 13/44] companion(ui): show uptime under the battery indicator A 64-bit Uptime counter is ticked from UITask::loop(), so it keeps counting while the screen is off and across the 32-bit millis() wrap. The page dots are left-aligned and their step shrinks only when they would run into the uptime (9 pages on a 128 px display). From PowerSaving-v17 54f292f4, 167a32fb and 4eae86fa. --- examples/companion_radio/ui-new/UITask.cpp | 38 +++++++++++++++++++--- src/helpers/Uptime.h | 10 ++++++ test/test_uptime/test_uptime.cpp | 8 +++++ 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 src/helpers/Uptime.h create mode 100644 test/test_uptime/test_uptime.cpp diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index bc25de79c17..7d134005cda 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1,3 +1,5 @@ +#include +static Uptime ui_uptime; #include "UITask.h" #include #include "../MyMesh.h" @@ -123,7 +125,8 @@ class HomeScreen : public UIScreen { bool discovery_disp_names = true; // by default desplay names if available (removes SNR_O) #endif - void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) { + // draws the battery and the uptime below it; returns the uptime's left x + int renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) { // Convert millivolts to percentage #ifndef BATT_MIN_MILLIVOLTS #define BATT_MIN_MILLIVOLTS 3000 @@ -174,6 +177,28 @@ class HomeScreen : public UIScreen { display.drawXbm(iconX - (charging ? 18 : 9), iconY + 1, muted_icon, 8, 8); } #endif + uint64_t uptimeMinutes = ui_uptime.milliseconds() / 60000ULL; + + unsigned long days = uptimeMinutes / 1440; + unsigned long hours = (uptimeMinutes % 1440) / 60; + unsigned long minutes = uptimeMinutes % 60; + char uptime[16]; + if (days > 0) { + snprintf(uptime, sizeof(uptime), "%lud %luh", days, hours); + } else if (hours > 0) { + snprintf(uptime, sizeof(uptime), "%luh %lum", hours, minutes); + } else { + snprintf(uptime, sizeof(uptime), "%lum", minutes); + } + + display.setColor(UIColor::title_txt); + display.setTextSize(1); + int uptimeWidth = display.getTextWidth(uptime); + int uptimeX = display.width() - uptimeWidth - 2; + int uptimeY = iconY + iconHeight + 1; + display.setCursor(uptimeX, uptimeY); + display.print(uptime); + return uptimeX; } CayenneLPP sensors_lpp; @@ -261,7 +286,7 @@ class HomeScreen : public UIScreen { display.print(filtered_name); // battery voltage - renderBatteryIndicator(display, _task->getBattMilliVolts()); + int uptimeX = renderBatteryIndicator(display, _task->getBattMilliVolts()); // curr page indicator if (UIColor::title_bkg == UIColor::window_bkg) { @@ -270,8 +295,12 @@ class HomeScreen : public UIScreen { display.setColor(UIColor::title_bkg); } int y = 14; - int x = display.width() / 2 - 5 * (HomePage::Count-1); - for (uint8_t i = 0; i < HomePage::Count; i++, x += 10) { + int x = 5; // left-aligned, leaving the right side for the uptime + int step = 10; + if (HomePage::Count > 1 && x + step * (HomePage::Count - 1) + 4 > uptimeX - 2) { + step = (uptimeX - 2 - 4 - x) / (HomePage::Count - 1); // squeeze the dots rather than overlap the uptime + } + for (uint8_t i = 0; i < HomePage::Count; i++, x += step) { if (i == _page) { display.fillRect(x-1, y-1, 4, 4); } else { @@ -921,6 +950,7 @@ bool UITask::isButtonPressed() const { } void UITask::loop() { + ui_uptime.tick(millis()); char c = 0; #if UI_HAS_JOYSTICK int ev = user_btn.check(); diff --git a/src/helpers/Uptime.h b/src/helpers/Uptime.h new file mode 100644 index 00000000000..5f54e5479a0 --- /dev/null +++ b/src/helpers/Uptime.h @@ -0,0 +1,10 @@ +#pragma once +#include +// Poll at least once per 32-bit millis() wrap, including when the screen is off. +class Uptime { + uint32_t _last = 0; + uint64_t _total = 0; +public: + void tick(uint32_t now) { _total += (uint32_t)(now - _last); _last = now; } + uint64_t milliseconds() const { return _total; } +}; diff --git a/test/test_uptime/test_uptime.cpp b/test/test_uptime/test_uptime.cpp new file mode 100644 index 00000000000..10d18df94a7 --- /dev/null +++ b/test/test_uptime/test_uptime.cpp @@ -0,0 +1,8 @@ +#include +#include "helpers/Uptime.h" +TEST(Uptime, IncludesTimeBeforeFirstPollAndSurvivesRollover) { + Uptime t; t.tick(1234); EXPECT_EQ(1234U,t.milliseconds()); + t.tick(0xFFFFFFF0U); t.tick(0x10U); EXPECT_EQ(0x100000010ULL,t.milliseconds()); + t.tick(0x10U); EXPECT_EQ(0x100000010ULL,t.milliseconds()); +} +int main(int argc,char**argv) { testing::InitGoogleTest(&argc,argv);return RUN_ALL_TESTS(); } From f6fb56246046ad022639eab643319b8ff7ec209c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 12:49:50 +0200 Subject: [PATCH 14/44] ui(st7735): restore the dark theme Black background with green/yellow text, as before v1.17. On the T096 the light theme costs about 3.5 mA more with the backlight on. From PowerSaving-v17 e1758d2e. --- src/helpers/ui/ST7735Display.cpp | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/helpers/ui/ST7735Display.cpp b/src/helpers/ui/ST7735Display.cpp index 344f5b1d9d3..bf25df26ce0 100644 --- a/src/helpers/ui/ST7735Display.cpp +++ b/src/helpers/ui/ST7735Display.cpp @@ -417,16 +417,16 @@ bool ST7735Display::i2c_probe(TwoWire& wire, uint8_t addr) { #define PIN_TFT_LEDA_CTL_ACTIVE HIGH #endif -// Color scheme -ColorVal UIColor::window_bkg = ST77XX_WHITE; -ColorVal UIColor::title_bkg = ST77XX_BLUE; -ColorVal UIColor::title_txt = ST77XX_WHITE; -ColorVal UIColor::primary_txt = ST77XX_BLACK; -ColorVal UIColor::secondary_txt = (18 << 11) | (36 << 5) | 18; // mid-gray -ColorVal UIColor::warning_txt = ST77XX_ORANGE; -ColorVal UIColor::popup_bkg = ST77XX_CYAN; -ColorVal UIColor::popup_txt = ST77XX_BLACK; -ColorVal UIColor::corp_blue = 0x001A; +// Color scheme - Dark theme before v1.17 +ColorVal UIColor::window_bkg = ST77XX_BLACK; +ColorVal UIColor::title_bkg = ST77XX_BLACK; +ColorVal UIColor::title_txt = ST77XX_GREEN; +ColorVal UIColor::primary_txt = ST77XX_GREEN; +ColorVal UIColor::secondary_txt = ST77XX_YELLOW; +ColorVal UIColor::warning_txt = ST77XX_RED; +ColorVal UIColor::popup_bkg = ST77XX_BLACK; +ColorVal UIColor::popup_txt = ST77XX_WHITE; +ColorVal UIColor::corp_blue = ST77XX_BLUE; bool ST7735Display::begin() { if (!sprite) { From b97f06d66092f2d2f23ef06a9e2401a43d9856ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 13:04:39 +0200 Subject: [PATCH 15/44] ui(st7735): stop driving the signal pins while the display is off With the display supply cut, CS/DC/SDA/SCL/RST/LEDA held as outputs back-power the panel (about 2.8 mA on the T096). turnOff() now releases the SPI bus and switches the pins to inputs; turnOn() restores CS/DC/RST/LEDA and starts the SPI bus again before re-init. SCK/MOSI must not be restored with pinMode(): on ESP32 it routes the pin back to plain GPIO, detaching it from the SPI peripheral, and on nRF52 it drops the SCK input buffer that SPIM needs. The panel then gets no init commands and wakes with the backlight on and a blank screen. From PowerSaving-v17 6e4e6f16. --- src/helpers/ui/ST7735Display.cpp | 38 ++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/helpers/ui/ST7735Display.cpp b/src/helpers/ui/ST7735Display.cpp index bf25df26ce0..5cde200acb8 100644 --- a/src/helpers/ui/ST7735Display.cpp +++ b/src/helpers/ui/ST7735Display.cpp @@ -428,6 +428,17 @@ ColorVal UIColor::popup_bkg = ST77XX_BLACK; ColorVal UIColor::popup_txt = ST77XX_WHITE; ColorVal UIColor::corp_blue = ST77XX_BLUE; +// Attaches SCK/MOSI to the SPI peripheral. pinMode() on them would detach it instead: +// ESP32 routes the pin back to plain GPIO, nRF52 drops the SCK input buffer SPIM needs. +static void beginSpi() { +#ifdef ESP_PLATFORM + _spi->begin(PIN_TFT_SCL, -1 /* _miso */, PIN_TFT_SDA /* _mosi */, -1); +#else + _spi->begin(); +#endif + _spi->setClockDivider(SPI_CLOCK_DIV2); +} + bool ST7735Display::begin() { if (!sprite) { // alloc offscreen canvas @@ -453,12 +464,7 @@ bool ST7735Display::begin() { pinMode(PIN_TFT_DC, OUTPUT); pinMode(PIN_TFT_LEDA_CTL, OUTPUT); -#ifdef ESP_PLATFORM - _spi->begin(PIN_TFT_SCL, -1 /* _miso */, PIN_TFT_SDA /* _mosi */, -1); -#else - _spi->begin(); -#endif - _spi->setClockDivider(SPI_CLOCK_DIV2); + beginSpi(); _height = 80; _width = 160; @@ -512,6 +518,14 @@ void ST7735Display::_resetAndInit() { void ST7735Display::turnOn() { if (!_isOn) { if (_peripher_power) _peripher_power->claim(); + + // Restore GPIOs + pinMode(PIN_TFT_CS, OUTPUT); + pinMode(PIN_TFT_DC, OUTPUT); + pinMode(PIN_TFT_RST, OUTPUT); + pinMode(PIN_TFT_LEDA_CTL, OUTPUT); + beginSpi(); + _resetAndInit(); sendCommand(ST77XX_DISPON); @@ -525,9 +539,19 @@ void ST7735Display::turnOff() { if (_isOn) { sendCommand(ST77XX_DISPOFF); - //digitalWrite(PIN_TFT_RST, LOW); + // digitalWrite(PIN_TFT_RST, LOW); // Now turn off the backlight digitalWrite(PIN_TFT_LEDA_CTL, !PIN_TFT_LEDA_CTL_ACTIVE); + + // Stop the signal pins from powering the display after its supply is cut. + _spi->end(); // release SCK/MOSI; turnOn() attaches them again + pinMode(PIN_TFT_CS, INPUT); + pinMode(PIN_TFT_DC, INPUT); + pinMode(PIN_TFT_SDA, INPUT); + pinMode(PIN_TFT_SCL, INPUT); + pinMode(PIN_TFT_RST, INPUT); + pinMode(PIN_TFT_LEDA_CTL, INPUT); + _isOn = false; if (_peripher_power) _peripher_power->release(); From 248f79fc402fc0222e08c3ab09ee785662cc18fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 13:30:16 +0200 Subject: [PATCH 16/44] ui(ssd1306): balance power claims and skip re-init on unswitched wake begin() claims the peripheral power only when the display was off and releases it again if no panel answers, so claims and releases stay balanced. turnOn() re-runs begin() only when the supply was switched off. Without switched power the panel kept its state, and a full begin() would pulse PIN_OLED_RESET, which defaults to 21 = SX126X_POWER_EN (FEM enable) on RAK3401 and cut the FEM for ~10 ms on every display wake. Host tests in test_ssd1306 use display mocks; run them with "pio test -e native_ssd1306". --- platformio.ini | 11 +++++- src/helpers/ui/SSD1306Display.cpp | 38 +++++++++++-------- test/display_mocks/Adafruit_GFX.h | 1 + test/display_mocks/Adafruit_SSD1306.h | 21 +++++++++++ test/display_mocks/Arduino.h | 8 ++++ test/display_mocks/Wire.h | 4 ++ test/test_ssd1306/test_ssd1306.cpp | 53 +++++++++++++++++++++++++++ 7 files changed, 120 insertions(+), 16 deletions(-) create mode 100644 test/display_mocks/Adafruit_GFX.h create mode 100644 test/display_mocks/Adafruit_SSD1306.h create mode 100644 test/display_mocks/Arduino.h create mode 100644 test/display_mocks/Wire.h create mode 100644 test/test_ssd1306/test_ssd1306.cpp diff --git a/platformio.ini b/platformio.ini index 36c07ec6ac5..9c2d2ea668e 100644 --- a/platformio.ini +++ b/platformio.ini @@ -170,7 +170,7 @@ build_flags = -std=c++17 -I src -I test/mocks test_build_src = yes -test_ignore = test_kiss_modem +test_ignore = test_kiss_modem, test_ssd1306 build_src_filter = -<*> +<../src/Utils.cpp> @@ -194,3 +194,12 @@ build_src_filter = +<../examples/kiss_modem/KissModem.cpp> lib_deps = google/googletest @ 1.17.0 + +[env:native_ssd1306] +extends = env:native +test_ignore = +test_filter = test_ssd1306 +build_flags = -std=c++17 -I test/display_mocks -I test/mocks -I src +; the suite #includes SSD1306Display.cpp itself; the display mocks shadow the +; Arduino headers the env:native sources need, so build none of them +build_src_filter = -<*> diff --git a/src/helpers/ui/SSD1306Display.cpp b/src/helpers/ui/SSD1306Display.cpp index ab2d77fbc33..53c53344d59 100644 --- a/src/helpers/ui/SSD1306Display.cpp +++ b/src/helpers/ui/SSD1306Display.cpp @@ -18,36 +18,44 @@ ColorVal UIColor::popup_txt = SSD1306_WHITE; ColorVal UIColor::corp_blue = SSD1306_WHITE; bool SSD1306Display::begin() { - if (!_isOn) { - if (_peripher_power) _peripher_power->claim(); - _isOn = true; + if (!_isOn && _peripher_power) _peripher_power->claim(); + const bool ok = display.begin(SSD1306_SWITCHCAPVCC, DISPLAY_ADDRESS, true, false) + && i2c_probe(Wire, DISPLAY_ADDRESS); + if (!ok) { + if (_peripher_power) _peripher_power->release(); + _isOn = false; + return false; } - #ifdef DISPLAY_ROTATION +#ifdef DISPLAY_ROTATION display.setRotation(DISPLAY_ROTATION); - #endif - return display.begin(SSD1306_SWITCHCAPVCC, DISPLAY_ADDRESS, true, false) && i2c_probe(Wire, DISPLAY_ADDRESS); +#endif + _isOn = true; + return true; } void SSD1306Display::turnOn() { if (!_isOn) { - if (_peripher_power) _peripher_power->claim(); - _isOn = true; // set before begin() to prevent double claim - if (_peripher_power) begin(); // re-init display after power was cut + if (_peripher_power) { + if (!begin()) return; // power was cut: claim it and re-init; released again if no display answers + } else { + // No switched supply, so the panel kept its state. A full begin() would also pulse + // PIN_OLED_RESET, which defaults to 21 = SX126X_POWER_EN (FEM enable) on RAK3401. + _isOn = true; + } } display.ssd1306_command(SSD1306_DISPLAYON); } void SSD1306Display::turnOff() { + if (!_isOn) return; display.ssd1306_command(SSD1306_DISPLAYOFF); - if (_isOn) { - if (_peripher_power) { + if (_peripher_power) { #if PIN_OLED_RESET >= 0 - digitalWrite(PIN_OLED_RESET, LOW); + digitalWrite(PIN_OLED_RESET, LOW); #endif - _peripher_power->release(); - } - _isOn = false; + _peripher_power->release(); } + _isOn = false; } void SSD1306Display::clear() { diff --git a/test/display_mocks/Adafruit_GFX.h b/test/display_mocks/Adafruit_GFX.h new file mode 100644 index 00000000000..6f70f09beec --- /dev/null +++ b/test/display_mocks/Adafruit_GFX.h @@ -0,0 +1 @@ +#pragma once diff --git a/test/display_mocks/Adafruit_SSD1306.h b/test/display_mocks/Adafruit_SSD1306.h new file mode 100644 index 00000000000..a452899f3b6 --- /dev/null +++ b/test/display_mocks/Adafruit_SSD1306.h @@ -0,0 +1,21 @@ +#pragma once +#include +#define SSD1306_BLACK 0 +#define SSD1306_WHITE 1 +#define SSD1306_SWITCHCAPVCC 2 +#define SSD1306_DISPLAYON 0xAF +#define SSD1306_DISPLAYOFF 0xAE +class Adafruit_SSD1306 { +public: + inline static bool init_ok=true; + inline static int starts=0, commands=0; + Adafruit_SSD1306(int,int,TwoWire*,int) {} + bool begin(int,int,bool,bool) { ++starts;return init_ok; } + void ssd1306_command(int) { ++commands; } + void setRotation(int) {} void clearDisplay() {} void display() {} + void setTextColor(int) {} void setTextSize(int) {} void cp437(bool) {} + void setCursor(int,int) {} void print(const char*) {} + void fillRect(int,int,int,int,int) {} void drawRect(int,int,int,int,int) {} + void drawBitmap(int,int,const uint8_t*,int,int,int) {} + void getTextBounds(const char*,int,int,int16_t*,int16_t*,uint16_t*,uint16_t*) {} +}; diff --git a/test/display_mocks/Arduino.h b/test/display_mocks/Arduino.h new file mode 100644 index 00000000000..c06ff8f54de --- /dev/null +++ b/test/display_mocks/Arduino.h @@ -0,0 +1,8 @@ +#pragma once +#include_next +#define HIGH 1 +#define LOW 0 +#define OUTPUT 1 +inline int pin_levels[64] = {}; +inline void pinMode(int, int) {} +inline void digitalWrite(int pin, int level) { pin_levels[pin] = level; } diff --git a/test/display_mocks/Wire.h b/test/display_mocks/Wire.h new file mode 100644 index 00000000000..7f7c426c2c4 --- /dev/null +++ b/test/display_mocks/Wire.h @@ -0,0 +1,4 @@ +#pragma once +#include +struct TwoWire { inline static int error=0; void beginTransmission(uint8_t) {} uint8_t endTransmission() { return error; } }; +inline TwoWire Wire; diff --git a/test/test_ssd1306/test_ssd1306.cpp b/test/test_ssd1306/test_ssd1306.cpp new file mode 100644 index 00000000000..4f9ad787592 --- /dev/null +++ b/test/test_ssd1306/test_ssd1306.cpp @@ -0,0 +1,53 @@ +#include +#include "helpers/ui/SSD1306Display.cpp" +class OLED : public testing::Test { +protected: + RefCountedDigitalPin power{7}; + SSD1306Display display{&power}; + void SetUp() override { power.begin(); Wire.error=0; Adafruit_SSD1306::init_ok=true; Adafruit_SSD1306::starts=Adafruit_SSD1306::commands=0; } +}; +TEST_F(OLED, FailedAllocationReleasesClaimAndDoesNotSendOn) { + Adafruit_SSD1306::init_ok=false; display.turnOn(); + EXPECT_FALSE(display.isOn()); EXPECT_EQ(LOW,pin_levels[7]); EXPECT_EQ(0,Adafruit_SSD1306::commands); +} +TEST_F(OLED, FailedProbeCanRetryAndRepeatedBeginDoesNotLeakClaim) { + Wire.error=4; EXPECT_FALSE(display.begin()); EXPECT_EQ(LOW,pin_levels[7]); + Wire.error=0; EXPECT_TRUE(display.begin()); EXPECT_TRUE(display.begin()); + display.turnOff(); EXPECT_EQ(LOW,pin_levels[7]); EXPECT_FALSE(display.isOn()); +} +TEST_F(OLED, FailureDuringReinitializationReleasesExistingClaim) { + ASSERT_TRUE(display.begin()); Wire.error=4; EXPECT_FALSE(display.begin()); + EXPECT_FALSE(display.isOn()); EXPECT_EQ(LOW,pin_levels[7]); +} +TEST_F(OLED, SharedSupplySurvivesFailureAndRepeatedOff) { + power.claim(); Wire.error=4; EXPECT_FALSE(display.begin()); + EXPECT_EQ(HIGH,pin_levels[7]); display.turnOff(); display.turnOff(); + EXPECT_EQ(HIGH,pin_levels[7]); power.release(); EXPECT_EQ(LOW,pin_levels[7]); +} +TEST_F(OLED, RepeatedPowerCyclesReinitializeOnceAndBalanceClaims) { + power.claim(); + for(int i=0;i<5;++i) { + display.turnOn(); display.turnOn(); EXPECT_TRUE(display.isOn()); + EXPECT_EQ(i+1,Adafruit_SSD1306::starts); + display.turnOff(); display.turnOff(); EXPECT_EQ(HIGH,pin_levels[7]); + } + power.release(); EXPECT_EQ(LOW,pin_levels[7]); +} +TEST(OLEDWithoutSwitchedPower, WakeDoesNotReinitializeOrPulseReset) { + Wire.error=0; Adafruit_SSD1306::init_ok=true; Adafruit_SSD1306::starts=Adafruit_SSD1306::commands=0; + SSD1306Display display; + ASSERT_TRUE(display.begin()); EXPECT_EQ(1,Adafruit_SSD1306::starts); + for(int i=0;i<3;++i) { + display.turnOff(); EXPECT_FALSE(display.isOn()); + display.turnOn(); EXPECT_TRUE(display.isOn()); + } + EXPECT_EQ(1,Adafruit_SSD1306::starts); // begin() pulses PIN_OLED_RESET; only the boot init may do that +} +TEST(OLEDWithoutSwitchedPower, MissingPanelIsNotRetriedOnWake) { + Wire.error=4; Adafruit_SSD1306::init_ok=true; Adafruit_SSD1306::starts=Adafruit_SSD1306::commands=0; + SSD1306Display display; + EXPECT_FALSE(display.begin()); display.turnOn(); display.turnOff(); display.turnOn(); + EXPECT_EQ(1,Adafruit_SSD1306::starts); + Wire.error=0; +} +int main(int argc,char**argv) { testing::InitGoogleTest(&argc,argv);return RUN_ALL_TESTS(); } From f08b68e4595fe1955ad57e2d7e013d0c63f575c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 13:44:06 +0200 Subject: [PATCH 17/44] cli: add 'get sensor' pin diagnostics and 'get powerlog' - get sensor: the I2C pins in use for environment sensors (Wire1 or Wire) and the GPS serial pins, with whether GPS is configured. - get powerlog: last reset reason and, where power management is initialised, the shutdown reason and boot voltage. From PowerSaving-v17 4cb67d90. --- src/helpers/CommonCLI.cpp | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 961e995531c..0d19d5b2304 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -461,6 +461,35 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } else { strcpy(reply, "off"); } + } else if (strcmp(command, "sensor") == 0) { +#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL) + snprintf(reply, 160, "I2C Wire1: SDA=%d, SCL=%d", (int)ENV_PIN_SDA, (int)ENV_PIN_SCL); +#elif defined(PIN_BOARD_SDA) && defined(PIN_BOARD_SCL) + snprintf(reply, 160, "I2C Wire: SDA=%d, SCL=%d", (int)PIN_BOARD_SDA, (int)PIN_BOARD_SCL); +#elif defined(PIN_WIRE_SDA) && defined(PIN_WIRE_SCL) + snprintf(reply, 160, "I2C Wire: SDA=%d, SCL=%d", (int)PIN_WIRE_SDA, (int)PIN_WIRE_SCL); +#else + snprintf(reply, 160, "I2C GPIOs not defined"); +#endif + size_t used = strlen(reply); +#if defined(PIN_GPS_RX) && defined(PIN_GPS_TX) + snprintf(reply + used, 160 - used, "; GPS RX=%d, TX=%d, %s", (int)PIN_GPS_RX, (int)PIN_GPS_TX, +#if defined(ENV_INCLUDE_GPS) && ENV_INCLUDE_GPS > 0 + "configured"); +#else + "not configured"); +#endif +#else + snprintf(reply + used, 160 - used, "; GPS serial not defined"); +#endif + } else if (strcmp(command, "powerlog") == 0) { + snprintf(reply, 160, "Last reset reason: %s", _board->getResetReasonString(_board->getResetReason())); + if (_board->isPwrMgtInitialised()) { + size_t used = strlen(reply); + snprintf(reply + used, 160 - used, "; shutdown: %s; boot: %u mV", + _board->getShutdownReasonString(_board->getShutdownReason()), + (unsigned)_board->getBootVoltage()); + } } else if (memcmp(command, "log start", 9) == 0) { _callbacks->setLoggingOn(true); strcpy(reply, " logging on"); From 484af95b462eb81adbc4485c6c86ca3d9164b2c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 13:57:24 +0200 Subject: [PATCH 18/44] variants(rak3401): use BSEC for the BME680 The BSEC library's extra_script selects the cortex-m4 soft-float blob, but nRF52840 builds with -mfloat-abi=hard, so linking failed. fix_bsec_lib.py prepends the fpv4-sp-d16-hard library path. From PowerSaving-v17 5965010f, 9a1be4bc and 9cd7a847. --- variants/rak3401/fix_bsec_lib.py | 15 +++++++++++++++ variants/rak3401/platformio.ini | 5 +++++ 2 files changed, 20 insertions(+) create mode 100644 variants/rak3401/fix_bsec_lib.py diff --git a/variants/rak3401/fix_bsec_lib.py b/variants/rak3401/fix_bsec_lib.py new file mode 100644 index 00000000000..e21e9e39b6a --- /dev/null +++ b/variants/rak3401/fix_bsec_lib.py @@ -0,0 +1,15 @@ +Import('env') +import os + +# Bosch has a goof in their PlatformIO packaging making linking fail. +# The BSEC library's extra_script.py selects cortex-m4/libalgobsec.a (soft-float ABI). +# nRF52840 compiles with -mfloat-abi=hard, requiring the fpv4-sp-d16-hard blob. +# Workaround to prepend the hard-float path so the linker finds it before the +# soft-float one. +bsec_hard = os.path.join( + env.subst('$PROJECT_DIR'), + '.pio', 'libdeps', env.subst('$PIOENV'), + 'BSEC Software Library', 'src', + 'cortex-m4', 'fpv4-sp-d16-hard' +) +env.Prepend(LIBPATH=[bsec_hard]) diff --git a/variants/rak3401/platformio.ini b/variants/rak3401/platformio.ini index ead8e84bee9..2ca47276fc3 100644 --- a/variants/rak3401/platformio.ini +++ b/variants/rak3401/platformio.ini @@ -2,11 +2,15 @@ extends = nrf52_base board = rak3401 board_check = true +extra_scripts = ${nrf52_base.extra_scripts} + post:variants/rak3401/fix_bsec_lib.py build_flags = ${nrf52_base.build_flags} ${sensor_base.build_flags} -I variants/rak3401 -D RAK_3401 -D RAK_BOARD + -UENV_INCLUDE_BME680 + -D ENV_INCLUDE_BME680_BSEC=1 -D RADIO_CLASS=CustomSX1262 -D WRAPPER_CLASS=CustomSX1262Wrapper -D LORA_TX_POWER=22 @@ -23,6 +27,7 @@ lib_deps = ${sensor_base.lib_deps} adafruit/Adafruit SSD1306 @ ^2.5.13 sparkfun/SparkFun u-blox GNSS Arduino Library@^2.2.27 + boschsensortec/BSEC Software Library @ ^1.8.1492 [env:RAK_3401_repeater] extends = rak3401 From e7c40ac7a47518b8c0a6923f98f301c93ecfe8c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 14:15:19 +0200 Subject: [PATCH 19/44] esp32: wake the repeater with the user button, then stay awake 30 s During light sleep PIN_USER_BTN is also a GPIO wake source. When the button is still pressed after wake-up, sleep is held off for 30 s so the button and display can be used; the hold-off uses a uint32_t millis() difference and survives the wrap. The TLoRa V2.1 repeater has no usable user button (GPIO0 wake kept it from booting), so it sets PIN_USER_BTN=-1 after the base flags, where the last -D wins. From PowerSaving-v17 da1cc88f and 374aba4f. --- src/helpers/ESP32Board.h | 20 +++++++++++++++++++- variants/lilygo_tlora_v2_1/platformio.ini | 1 + 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 3f95e6923f4..2e992559970 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -21,6 +21,8 @@ class ESP32Board : public mesh::MainBoard { protected: uint8_t startup_reason; bool inhibit_sleep = false; + uint32_t button_woke_at = 0; + bool button_sleep_inhibited = false; static inline portMUX_TYPE sleepMux = portMUX_INITIALIZER_UNLOCKED; public: @@ -75,7 +77,10 @@ class ESP32Board : public mesh::MainBoard { void sleep(uint32_t secs) override { // Skip if not allow to sleep - if (inhibit_sleep) { + if (button_sleep_inhibited && (uint32_t)(millis() - button_woke_at) >= 30000) { + button_sleep_inhibited = false; + } + if (inhibit_sleep || button_sleep_inhibited) { delay(1); // Give MCU to OTA to run return; } @@ -102,6 +107,11 @@ class ESP32Board : public mesh::MainBoard { esp_sleep_enable_gpio_wakeup(); gpio_wakeup_enable((gpio_num_t)wakeupPin, GPIO_INTR_HIGH_LEVEL); // Wake up when receiving a LoRa packet +#if defined(PIN_USER_BTN) && (PIN_USER_BTN >= 0) + gpio_wakeup_enable((gpio_num_t)PIN_USER_BTN, + USER_BTN_PRESSED == LOW ? GPIO_INTR_LOW_LEVEL : GPIO_INTR_HIGH_LEVEL); +#endif + // MCU enters light sleep esp_light_sleep_start(); @@ -109,6 +119,14 @@ class ESP32Board : public mesh::MainBoard { gpio_wakeup_disable(wakeupPin); gpio_set_intr_type(wakeupPin, GPIO_INTR_POSEDGE); +#if defined(PIN_USER_BTN) && (PIN_USER_BTN >= 0) + gpio_wakeup_disable((gpio_num_t)PIN_USER_BTN); + if (gpio_get_level((gpio_num_t)PIN_USER_BTN) == USER_BTN_PRESSED) { + button_woke_at = millis(); + button_sleep_inhibited = true; + } +#endif + // Enable CPU interrupt servicing portEXIT_CRITICAL(&sleepMux); } diff --git a/variants/lilygo_tlora_v2_1/platformio.ini b/variants/lilygo_tlora_v2_1/platformio.ini index dc60b6f75e4..6e8e08168fb 100644 --- a/variants/lilygo_tlora_v2_1/platformio.ini +++ b/variants/lilygo_tlora_v2_1/platformio.ini @@ -44,6 +44,7 @@ build_src_filter = ${LilyGo_TLora_V2_1_1_6.build_src_filter} +<../examples/simple_repeater> build_flags = ${LilyGo_TLora_V2_1_1_6.build_flags} + -D PIN_USER_BTN=-1 ; after the base flags: the last -D wins. GPIO0 wake kept this repeater from booting (v17) -D ADVERT_NAME='"TLora-V2.1-1.6 Repeater"' -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 From c126a62431b8d62f15e1870fa8771e4dce15b806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 14:32:54 +0200 Subject: [PATCH 20/44] esp32: build ESP-NOW boards without LoRa pins Generic_ESPNOW_* envs did not build: ESP32Board used P_LORA_DIO_1 and P_LORA_NSS unconditionally. Without them getIRQGpio() falls back to "none", sleep() stays awake (there is no LoRa wake source and Wi-Fi must keep receiving) and the NSS hold in shutdownPeripherals() is skipped. ESPNOWRadio::setRxBoostedGainMode() now returns a value. --- src/helpers/ESP32Board.cpp | 2 ++ src/helpers/ESP32Board.h | 11 ++++++++++- src/helpers/esp32/ESPNOWRadio.h | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index a55abb264e9..3614daeee9d 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -59,11 +59,13 @@ void ESP32Board::enterDeepSleep(uint32_t secs) { radio_driver.powerOff(); // Keep LoRa inactive during deepsleep +#ifdef P_LORA_NSS // not defined for ESP-NOW-only boards digitalWrite(P_LORA_NSS, HIGH); #if defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32C6) gpio_hold_en((gpio_num_t)P_LORA_NSS); #else rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); +#endif #endif // Power off GPS if any diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 2e992559970..ba986bc12ee 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -72,7 +72,11 @@ class ESP32Board : public mesh::MainBoard { void enterDeepSleep(uint32_t secs); uint32_t getIRQGpio() override { +#ifdef P_LORA_DIO_1 return P_LORA_DIO_1; // default for SX1262 +#else + return mesh::MainBoard::getIRQGpio(); // no LoRa radio (ESP-NOW) +#endif } void sleep(uint32_t secs) override { @@ -86,7 +90,12 @@ class ESP32Board : public mesh::MainBoard { } // Set GPIO wakeup - gpio_num_t wakeupPin = (gpio_num_t)getIRQGpio(); + const uint32_t irq_gpio = getIRQGpio(); + if (irq_gpio == (uint32_t)-1) { + delay(1); // no LoRa wake source (ESP-NOW): stay awake so Wi-Fi keeps receiving + return; + } + gpio_num_t wakeupPin = (gpio_num_t)irq_gpio; // Configure timer wakeup if (secs > 0) { diff --git a/src/helpers/esp32/ESPNOWRadio.h b/src/helpers/esp32/ESPNOWRadio.h index f474215a155..bda44a7ebb4 100644 --- a/src/helpers/esp32/ESPNOWRadio.h +++ b/src/helpers/esp32/ESPNOWRadio.h @@ -38,7 +38,7 @@ class ESPNOWRadio : public mesh::Radio { * These two functions do nothing for ESP-NOW, but are needed for the * Radio interface. */ - virtual bool setRxBoostedGainMode(bool) { } + virtual bool setRxBoostedGainMode(bool) { return false; } virtual bool getRxBoostedGainMode() const { return false; } uint32_t intID(); From 17086fefa51eab8e00fe17c11102fa88316e9670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 15:08:28 +0200 Subject: [PATCH 21/44] feat: periodic reboot, persisted and deferred during TX or OTA 'set reboot.interval <0-255>' (hours since boot, 0 disables) for the repeater, room server and sensor. The value takes a former padding byte of the prefs file. A due reboot waits until the current transmission and any active OTA session have finished. From PowerSaving-v17 00242269 and 4cb67d90. --- docs/cli_commands.md | 4 +++ examples/simple_repeater/main.cpp | 7 +++++ examples/simple_room_server/main.cpp | 7 +++++ examples/simple_sensor/main.cpp | 7 +++++ src/Dispatcher.h | 1 + src/MeshCore.h | 3 +++ src/helpers/CommonCLI.cpp | 19 +++++++++++--- src/helpers/CommonCLI.h | 2 ++ src/helpers/ESP32Board.cpp | 1 + src/helpers/NRF52Board.cpp | 1 + src/helpers/PeriodicReboot.h | 22 ++++++++++++++++ .../test_periodic_reboot.cpp | 26 +++++++++++++++++++ 12 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 src/helpers/PeriodicReboot.h create mode 100644 test/test_periodic_reboot/test_periodic_reboot.cpp diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 9fde4d436e5..b3ddd5a89db 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1196,3 +1196,7 @@ Ethernet support is available on RAK4631 boards with a RAK13800 (W5100S) Etherne - Connect with any TCP client (e.g. `nc`, PuTTY) to access the same CLI available over serial. --- + +### Periodic reboot + +`get reboot.interval` / `set reboot.interval <0-255>`: hours since boot (0 disables). Repeater, Room Server and Sensor persist this setting. A due reboot waits for current TX and an active OTA session to finish. diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index d475b13d842..d8950dcd069 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -1,3 +1,5 @@ +#include +static PeriodicReboot periodic_reboot; #include // needed for PlatformIO #include @@ -214,4 +216,9 @@ void loop() { } #endif } + if (periodic_reboot.due(the_mesh.getNodePrefs()->reboot_interval, millis(), + the_mesh.isTransmitting(), board.isOTAUpdateActive())) { + board.reboot(); + } + } diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index 65fc55ef7c4..eb6438f3e7a 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -1,3 +1,5 @@ +#include +static PeriodicReboot periodic_reboot; #include // needed for PlatformIO #include @@ -166,4 +168,9 @@ void loop() { #ifdef HAS_EXTERNAL_WATCHDOG external_watchdog.loop(); #endif + if (periodic_reboot.due(the_mesh.getNodePrefs()->reboot_interval, millis(), + the_mesh.isTransmitting(), board.isOTAUpdateActive())) { + board.reboot(); + } + } diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index 66991daada5..a8dfdd1a565 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -1,3 +1,5 @@ +#include +static PeriodicReboot periodic_reboot; #include "SensorMesh.h" #ifdef DISPLAY_CLASS @@ -164,4 +166,9 @@ void loop() { #ifdef HAS_EXTERNAL_WATCHDOG external_watchdog.loop(); #endif + if (periodic_reboot.due(the_mesh.getNodePrefs()->reboot_interval, millis(), + the_mesh.isTransmitting(), board.isOTAUpdateActive())) { + board.reboot(); + } + } diff --git a/src/Dispatcher.h b/src/Dispatcher.h index aad6cba3ec2..fdb13577f00 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -173,6 +173,7 @@ class Dispatcher { virtual unsigned long getDutyCycleWindowMs() const { return 3600000; } public: + bool isTransmitting() const { return outbound != nullptr; } void begin(); void loop(); diff --git a/src/MeshCore.h b/src/MeshCore.h index b9cfb418283..45d8071bec9 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -43,7 +43,10 @@ namespace mesh { #define BD_STARTUP_RX_PACKET 1 class MainBoard { +protected: + bool _ota_update_active = false; public: + virtual bool isOTAUpdateActive() const { return _ota_update_active; } virtual uint16_t getBattMilliVolts() = 0; virtual float getMCUTemperature() { return NAN; } virtual bool setAdcMultiplier(float multiplier) { return false; }; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 0d19d5b2304..9a56e1a5efd 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -1,3 +1,4 @@ +#include "PeriodicReboot.h" #include #include "CommonCLI.h" #include "TxtDataHelpers.h" @@ -90,7 +91,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Legacy file.read((uint8_t *)&_prefs->bridge_channel, sizeof(_prefs->bridge_channel)); // 135 file.read((uint8_t *)&_prefs->bridge_secret, sizeof(_prefs->bridge_secret)); // 136 file.read((uint8_t *)&_prefs->powersaving_enabled, sizeof(_prefs->powersaving_enabled)); // 152 - file.read(pad, 3); // 153 + file.read((uint8_t *)&_prefs->reboot_interval, sizeof(_prefs->reboot_interval)); // 153 + file.read(pad, 2); // 154 file.read((uint8_t *)&_prefs->gps_enabled, sizeof(_prefs->gps_enabled)); // 156 file.read((uint8_t *)&_prefs->gps_interval, sizeof(_prefs->gps_interval)); // 157 file.read((uint8_t *)&_prefs->advert_loc_policy, sizeof (_prefs->advert_loc_policy)); // 161 @@ -516,7 +518,16 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* reply) { const char* config = &command[4]; - if (memcmp(config, "allow.read.only ", 16) == 0) { + if (strncmp(config, "reboot.interval ", 16) == 0) { + uint8_t hours; + if (!PeriodicReboot::parseHours(config + 16, hours)) { + strcpy(reply, "Error: interval range is 0-255 hours"); + } else { + _prefs->reboot_interval = hours; + savePrefs(); + sprintf(reply, "OK - reboot.interval %u h", (unsigned)hours); + } + } else if (memcmp(config, "allow.read.only ", 16) == 0) { _prefs->allow_read_only = memcmp(&config[16], "on", 2) == 0; savePrefs(); strcpy(reply, "OK"); @@ -730,7 +741,9 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* reply) { const char* config = &command[4]; - if (memcmp(config, "allow.read.only", 15) == 0) { + if (strcmp(config, "reboot.interval") == 0) { + sprintf(reply, "> %u", (unsigned)_prefs->reboot_interval); + } else if (memcmp(config, "allow.read.only", 15) == 0) { sprintf(reply, "> %s", _prefs->allow_read_only ? "on" : "off"); } else if (memcmp(config, "flood.advert.interval", 21) == 0) { sprintf(reply, "> %d", ((uint32_t) _prefs->flood_advert_interval)); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 2759b0c4f0e..a21c322d083 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -57,6 +57,7 @@ class NodePrefs : public ConfigSerializer { uint8_t bridge_channel = 0; // 1-14 (ESP-NOW only) char bridge_secret[16]; // for XOR encryption of bridge packets (ESP-NOW only) // Power setting + uint8_t reboot_interval = 0; // hours; zero disables automatic restart uint8_t powersaving_enabled = 0; // boolean // Gps settings uint8_t gps_enabled = 0; @@ -171,6 +172,7 @@ class NodePrefs : public ConfigSerializer { void structure() override { def("adc_mult", _parent->adc_multiplier); def("pwr_sav_en", _parent->powersaving_enabled); + def("pwr_reboot_int", _parent->reboot_interval); } public: PowerPrefs(NodePrefs* parent) : _parent(parent) { } diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 3614daeee9d..c48916e058b 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -12,6 +12,7 @@ #include bool ESP32Board::startOTAUpdate(const char* id, char reply[]) { + _ota_update_active = true; inhibit_sleep = true; // prevent sleep during OTA WiFi.softAP("MeshCore-OTA", NULL); diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index f2fa7743a73..f514016f84a 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -417,6 +417,7 @@ bool NRF52Board::startOTAUpdate(const char *id, char reply[]) { Bluefruit.configPrphConn(92, BLE_GAP_EVENT_LENGTH_MIN, 16, 16); if (!Bluefruit.begin(1, 0)) return false; + _ota_update_active = true; // Set max power. Accepted values are: -40, -30, -20, -16, -12, -8, -4, 0, 4 Bluefruit.setTxPower(4); diff --git a/src/helpers/PeriodicReboot.h b/src/helpers/PeriodicReboot.h new file mode 100644 index 00000000000..63915d8e904 --- /dev/null +++ b/src/helpers/PeriodicReboot.h @@ -0,0 +1,22 @@ +#pragma once +#include "Uptime.h" +class PeriodicReboot { + Uptime _uptime; +public: + bool due(uint8_t hours, uint32_t now, bool transmitting, bool ota_active) { + _uptime.tick(now); + return hours != 0 && _uptime.milliseconds() >= (uint64_t)hours * 3600000ULL + && !transmitting && !ota_active; + } + static bool parseHours(const char* text, uint8_t& hours) { + if (!text || !*text) return false; + unsigned value = 0; + for (; *text; ++text) { + if (*text < '0' || *text > '9') return false; + value = value * 10 + (*text - '0'); + if (value > 255) return false; + } + hours = value; + return true; + } +}; diff --git a/test/test_periodic_reboot/test_periodic_reboot.cpp b/test/test_periodic_reboot/test_periodic_reboot.cpp new file mode 100644 index 00000000000..7999611c957 --- /dev/null +++ b/test/test_periodic_reboot/test_periodic_reboot.cpp @@ -0,0 +1,26 @@ +#include +#include "helpers/PeriodicReboot.h" +TEST(PeriodicReboot, DeadlineDefersDuringTransmitAndOTA) { + PeriodicReboot timer; + EXPECT_FALSE(timer.due(1,3599999,false,false)); + EXPECT_FALSE(timer.due(1,3600000,true,false)); + EXPECT_FALSE(timer.due(1,3600001,false,true)); + EXPECT_TRUE(timer.due(1,3600002,false,false)); + EXPECT_FALSE(timer.due(0,3600003,false,false)); +} +TEST(PeriodicReboot, LongUptimeAndRolloverKeepTheDeadline) { + PeriodicReboot timer; + EXPECT_FALSE(timer.due(255,900000000,false,false)); + EXPECT_TRUE(timer.due(255,918000000,false,false)); + EXPECT_FALSE(timer.due(0,0xFFFFFFF0U,false,false)); + EXPECT_TRUE(timer.due(255,0x10U,false,false)); +} +TEST(PeriodicReboot, StrictRangeAndNoPartialMutation) { + uint8_t hours=7; + for(const char* bad : {"", "-1", "abc", "256", "9999999999999", "1x"}) { + EXPECT_FALSE(PeriodicReboot::parseHours(bad,hours)); EXPECT_EQ(7,hours); + } + EXPECT_TRUE(PeriodicReboot::parseHours("0",hours)); EXPECT_EQ(0,hours); + EXPECT_TRUE(PeriodicReboot::parseHours("255",hours)); EXPECT_EQ(255,hours); +} +int main(int argc,char**argv) { testing::InitGoogleTest(&argc,argv);return RUN_ALL_TESTS(); } From 3329467320437ed6d73e6dd146ccdb7a4fe3e203 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 15:22:01 +0200 Subject: [PATCH 22/44] prefs: powersaving on by default for fresh installs Repeater, room server and sensor start with powersaving_enabled = 1. A saved setting still wins. From PowerSaving-v17 9414de85. --- docs/cli_commands.md | 2 +- src/helpers/CommonCLI.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index b3ddd5a89db..27d30dba1d3 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -455,7 +455,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore - `on`: enable power saving - `off`: disable power saving -**Default:** `off` +**Default:** `on` for fresh installs **Note:** When enabled, device enters sleep mode between radio transmissions diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index a21c322d083..db4ac0de5bf 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -58,7 +58,7 @@ class NodePrefs : public ConfigSerializer { char bridge_secret[16]; // for XOR encryption of bridge packets (ESP-NOW only) // Power setting uint8_t reboot_interval = 0; // hours; zero disables automatic restart - uint8_t powersaving_enabled = 0; // boolean + uint8_t powersaving_enabled = 1; // boolean // Gps settings uint8_t gps_enabled = 0; uint32_t gps_interval = 0; // in seconds From 6f42c742c1e8bc0ac35c878248bffd3d4bbaaef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 15:48:52 +0200 Subject: [PATCH 23/44] serial: report queued received frames through isReadBusy() A companion must not go idle while received frames wait to be processed. BaseSerialInterface defaults to false, so Wi-Fi, RP2040 BLE, Ethernet and Arduino serial need no change. ESP32 and nRF52 BLE report their receive queues, and MultiSerialInterface aggregates the enabled interfaces. From PowerSaving-v17 594d7709. --- src/helpers/BaseSerialInterface.h | 1 + src/helpers/MultiSerialInterface.h | 17 ++++++++++++++ src/helpers/esp32/SerialBLEInterface.cpp | 4 ++++ src/helpers/esp32/SerialBLEInterface.h | 1 + src/helpers/nrf52/SerialBLEInterface.cpp | 4 ++++ src/helpers/nrf52/SerialBLEInterface.h | 1 + test/test_serial_idle/test_serial_idle.cpp | 26 ++++++++++++++++++++++ 7 files changed, 54 insertions(+) create mode 100644 test/test_serial_idle/test_serial_idle.cpp diff --git a/src/helpers/BaseSerialInterface.h b/src/helpers/BaseSerialInterface.h index 23933fcb4b0..cb31a47ed46 100644 --- a/src/helpers/BaseSerialInterface.h +++ b/src/helpers/BaseSerialInterface.h @@ -16,6 +16,7 @@ class BaseSerialInterface { virtual bool isConnected() const = 0; virtual void loop() {}; + virtual bool isReadBusy() const { return false; } // received frames still queued (power saving) virtual bool isWriteBusy() const = 0; virtual size_t writeFrame(const uint8_t src[], size_t len) = 0; virtual size_t checkRecvFrame(uint8_t dest[]) = 0; diff --git a/src/helpers/MultiSerialInterface.h b/src/helpers/MultiSerialInterface.h index f7742b24c38..7bb827c18b8 100644 --- a/src/helpers/MultiSerialInterface.h +++ b/src/helpers/MultiSerialInterface.h @@ -140,6 +140,23 @@ class MultiSerialInterface : public BaseSerialInterface { } } + bool isReadBusy() const override { + // not busy when disabled + if (!_enabled) { + return false; + } + + // check if any interface is busy + for (auto iface : _interfaces) { + if (iface.instance && iface.instance->isEnabled() && iface.instance->isReadBusy()) { + return true; + } + } + + // nothing busy + return false; + } + bool isWriteBusy() const override { // not busy when disabled if(!_enabled){ diff --git a/src/helpers/esp32/SerialBLEInterface.cpp b/src/helpers/esp32/SerialBLEInterface.cpp index 6371cf33537..2e1343f5470 100644 --- a/src/helpers/esp32/SerialBLEInterface.cpp +++ b/src/helpers/esp32/SerialBLEInterface.cpp @@ -188,6 +188,10 @@ size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) { #define BLE_WRITE_MIN_INTERVAL 60 +bool SerialBLEInterface::isReadBusy() const { + return uxQueueMessagesWaiting(recv_queue) > 0; +} + bool SerialBLEInterface::isWriteBusy() const { return millis() < _last_write + BLE_WRITE_MIN_INTERVAL; // still too soon to start another write? } diff --git a/src/helpers/esp32/SerialBLEInterface.h b/src/helpers/esp32/SerialBLEInterface.h index 9fb6adba943..3298011b2b8 100644 --- a/src/helpers/esp32/SerialBLEInterface.h +++ b/src/helpers/esp32/SerialBLEInterface.h @@ -82,6 +82,7 @@ class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLE bool isConnected() const override; + bool isReadBusy() const override; bool isWriteBusy() const override; size_t writeFrame(const uint8_t src[], size_t len) override; size_t checkRecvFrame(uint8_t dest[]) override; diff --git a/src/helpers/nrf52/SerialBLEInterface.cpp b/src/helpers/nrf52/SerialBLEInterface.cpp index 6ed16bc86d9..b1f5d95ce56 100644 --- a/src/helpers/nrf52/SerialBLEInterface.cpp +++ b/src/helpers/nrf52/SerialBLEInterface.cpp @@ -405,6 +405,10 @@ bool SerialBLEInterface::isConnected() const { return _isDeviceConnected && Bluefruit.connected() > 0; } +bool SerialBLEInterface::isReadBusy() const { + return (recv_queue_len > 0); +} + bool SerialBLEInterface::isWriteBusy() const { return send_queue_len >= (FRAME_QUEUE_SIZE * 2 / 3); } diff --git a/src/helpers/nrf52/SerialBLEInterface.h b/src/helpers/nrf52/SerialBLEInterface.h index de1030548fb..d3cc5055167 100644 --- a/src/helpers/nrf52/SerialBLEInterface.h +++ b/src/helpers/nrf52/SerialBLEInterface.h @@ -66,6 +66,7 @@ class SerialBLEInterface : public BaseSerialInterface { void disable() override; bool isEnabled() const override { return _isEnabled; } bool isConnected() const override; + bool isReadBusy() const override; bool isWriteBusy() const override; size_t writeFrame(const uint8_t src[], size_t len) override; size_t checkRecvFrame(uint8_t dest[]) override; diff --git a/test/test_serial_idle/test_serial_idle.cpp b/test/test_serial_idle/test_serial_idle.cpp new file mode 100644 index 00000000000..2cec47d7082 --- /dev/null +++ b/test/test_serial_idle/test_serial_idle.cpp @@ -0,0 +1,26 @@ +#include +#include +struct QueuedSerial : BaseSerialInterface { + bool enabled=false, queued=false; + void enable() override {enabled=true;} + void disable() override {enabled=false;} + bool isEnabled() const override {return enabled;} + bool isConnected() const override {return enabled;} + bool isReadBusy() const override {return queued;} + bool isWriteBusy() const override {return false;} + size_t writeFrame(const uint8_t*,size_t len) override {return len;} + size_t checkRecvFrame(uint8_t*) override {bool had=queued;queued=false;return had?1:0;} +}; +TEST(CompanionIdle, QueuedFrameHoldsIdleUntilConsumed) { + MultiSerialInterface interfaces;QueuedSerial ble;uint8_t buffer[8]; + ASSERT_TRUE(interfaces.addInterface(InterfaceType::Bluetooth,&ble));interfaces.enable(); + ble.queued=true;EXPECT_TRUE(interfaces.isReadBusy()); + EXPECT_EQ(1U,interfaces.checkRecvFrame(buffer));EXPECT_FALSE(interfaces.isReadBusy()); +} +TEST(CompanionIdle, DisabledInterfaceDoesNotBlockOtherInterfaces) { + MultiSerialInterface interfaces;QueuedSerial ble,usb; + interfaces.addInterface(InterfaceType::Bluetooth,&ble);interfaces.addInterface(InterfaceType::USB,&usb); + interfaces.enable();ble.queued=true;interfaces.disableBluetooth();EXPECT_FALSE(interfaces.isReadBusy()); + usb.queued=true;EXPECT_TRUE(interfaces.isReadBusy());interfaces.disable();EXPECT_FALSE(interfaces.isReadBusy()); +} +int main(int argc,char** argv){testing::InitGoogleTest(&argc,argv);return RUN_ALL_TESTS();} From 1446a5b7be770d7ad0ffd98567d313dd5257c5db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 16:03:15 +0200 Subject: [PATCH 24/44] repeater: do not sleep while rxdelay packets wait in the inbound queue With rxdelay, received packets are queued for later processing. The repeater's hasPendingWork() only looked at the outbound queue, so the MCU could sleep past their scheduled time. PacketManager now reports the inbound queue length (StaticPoolPacketManager: rx_queue) and the repeater counts it as pending work. --- examples/simple_repeater/MyMesh.cpp | 2 +- src/Dispatcher.h | 1 + src/helpers/StaticPoolPacketManager.h | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 61b6c149e98..f063df6e4c1 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1336,5 +1336,5 @@ bool MyMesh::hasPendingWork() const { #if defined(WITH_BRIDGE) if (bridge.isRunning()) return true; // bridge needs WiFi radio, can't sleep #endif - return _mgr->getOutboundTotal() > 0; + return _mgr->getOutboundTotal() > 0 || _mgr->getInboundTotal() > 0; // rxdelay packets wait in the inbound queue } diff --git a/src/Dispatcher.h b/src/Dispatcher.h index fdb13577f00..76fc22a70f1 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -98,6 +98,7 @@ class PacketManager { virtual Packet* removeOutboundByIdx(int i) = 0; virtual void queueInbound(Packet* packet, uint32_t scheduled_for) = 0; virtual Packet* getNextInbound(uint32_t now) = 0; + virtual int getInboundTotal() const { return 0; } // delayed (rxdelay) packets not yet processed }; typedef uint32_t DispatcherAction; diff --git a/src/helpers/StaticPoolPacketManager.h b/src/helpers/StaticPoolPacketManager.h index f63700e519a..b746662535e 100644 --- a/src/helpers/StaticPoolPacketManager.h +++ b/src/helpers/StaticPoolPacketManager.h @@ -38,4 +38,5 @@ class StaticPoolPacketManager : public mesh::PacketManager { mesh::Packet* removeOutboundByIdx(int i) override; void queueInbound(mesh::Packet* packet, uint32_t scheduled_for) override; mesh::Packet* getNextInbound(uint32_t now) override; + int getInboundTotal() const override { return rx_queue.count(); } }; \ No newline at end of file From decf16f4e8e55b2e2e59488be2666239ef210411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 16:45:30 +0200 Subject: [PATCH 25/44] variants: add MKE-S3 (ESP32-S3 + SX1262) Repeater, room server, sensor, companion BLE/USB/Wi-Fi, ESP-NOW bridge and terminal chat envs; BOOT works as the user button. No RS232 bridge env: in the v17 wiring GPIO5 is both the SX1262 reset and the bridge RX. PinChecks.h rejects any build where the bridge RX or TX pin equals P_LORA_RESET. From PowerSaving-v17 f024668a, a4fb9d29, 96397639, 3d830a9c and fc182b77. --- variants/mke_s3/MKES3Board.h | 13 ++ variants/mke_s3/PinChecks.h | 4 + variants/mke_s3/platformio.ini | 212 +++++++++++++++++++++++++++++++++ variants/mke_s3/target.cpp | 46 +++++++ variants/mke_s3/target.h | 29 +++++ 5 files changed, 304 insertions(+) create mode 100644 variants/mke_s3/MKES3Board.h create mode 100644 variants/mke_s3/PinChecks.h create mode 100644 variants/mke_s3/platformio.ini create mode 100644 variants/mke_s3/target.cpp create mode 100644 variants/mke_s3/target.h diff --git a/variants/mke_s3/MKES3Board.h b/variants/mke_s3/MKES3Board.h new file mode 100644 index 00000000000..a0ccf9e1328 --- /dev/null +++ b/variants/mke_s3/MKES3Board.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +class MKES3Board : public ESP32Board { +public: + MKES3Board() { } + + const char* getManufacturerName() const override { + return "MKE S3"; + } +}; diff --git a/variants/mke_s3/PinChecks.h b/variants/mke_s3/PinChecks.h new file mode 100644 index 00000000000..9df5afd21f8 --- /dev/null +++ b/variants/mke_s3/PinChecks.h @@ -0,0 +1,4 @@ +#pragma once +#if defined(WITH_RS232_BRIDGE) && ((WITH_RS232_BRIDGE_RX == P_LORA_RESET) || (WITH_RS232_BRIDGE_TX == P_LORA_RESET)) +#error "MKE-S3 RS232 pins conflict with LoRa reset; confirm separate RX/TX wiring" +#endif diff --git a/variants/mke_s3/platformio.ini b/variants/mke_s3/platformio.ini new file mode 100644 index 00000000000..4bcf05f9279 --- /dev/null +++ b/variants/mke_s3/platformio.ini @@ -0,0 +1,212 @@ +; https://github.com/makereduvn/MKE-K01-ESP32-S3-DEV-KIT +[MKE_s3] +extends = esp32_base +board = esp32-s3-devkitc-1 +build_flags = + ${esp32_base.build_flags} + ${sensor_base.build_flags} + -I variants/mke_s3 + -D MKE_S3 + -D ESP32_CPU_FREQ=80 + ; First heading + -D P_LORA_NSS=14 + -D P_LORA_MOSI=13 + -D P_LORA_MISO=12 + -D P_LORA_SCLK=11 + -D P_LORA_BUSY=10 + ; Second heading + ; GND + ; 3.3v + -D P_LORA_RESET=5 + -D P_LORA_DIO_1=4 + -D USE_SX1262 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=22 +; -D P_LORA_TX_LED=48 ; RGB LED + -D PIN_BOARD_SDA=8 + -D PIN_BOARD_SCL=9 + -D PIN_USER_BTN=0 +; -D PIN_VEXT_EN=36 + -D SX126X_DIO2_AS_RF_SWITCH=true + -D SX126X_DIO3_TCXO_VOLTAGE=0 ; Ra01SH uses XTAL + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 + -D PIN_GPS_RX=47 + -D PIN_GPS_TX=48 + -D PIN_GPS_EN=26 +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/mke_s3> + + +lib_deps = + ${esp32_base.lib_deps} + ${sensor_base.lib_deps} + adafruit/Adafruit SSD1306 @ ^2.5.13 + +[env:MKE_s3_repeater] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"MKE Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + + + +<../examples/simple_repeater> +lib_deps = + ${MKE_s3.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +; RS232 environment withheld: v17 assigned GPIO5 to both LoRa reset and RX. +; Publish only after RX/TX wiring is confirmed and conflict checks pass. + +[env:MKE_s3_repeater_bridge_espnow] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"ESPNow Bridge"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_ESPNOW_BRIDGE=1 +; -D BRIDGE_DEBUG=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + + + + + +<../examples/simple_repeater> +lib_deps = + ${MKE_s3.lib_deps} + ${esp32_ota.lib_deps} + +[env:MKE_s3_room_server] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"MKE RoomServer"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + + + +<../examples/simple_room_server> +lib_deps = + ${MKE_s3.lib_deps} + ${esp32_ota.lib_deps} + +[env:MKE_s3_terminal_chat] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + +<../examples/simple_secure_chat/main.cpp> +lib_deps = + ${MKE_s3.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:MKE_s3_companion_radio_usb] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=SSD1306Display + -D ENABLE_USB_INTERFACE +; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 +; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${MKE_s3.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:MKE_s3_companion_radio_ble] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=SSD1306Display + -D BLE_PIN_CODE=123456 ; fixed PIN if display is not detected + -D AUTO_SHUTDOWN_MILLIVOLTS=3400 +; -D BLE_DEBUG_LOGGING=1 + -D OFFLINE_QUEUE_SIZE=256 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${MKE_s3.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:MKE_s3_companion_radio_wifi] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=SSD1306Display +; -D WIFI_DEBUG_LOGGING=1 + -D ENABLE_WIFI_INTERFACE + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D OFFLINE_QUEUE_SIZE=256 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + + + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${MKE_s3.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:MKE_s3_sensor] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -D ADVERT_NAME='"MKE Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ENV_PIN_SDA=33 + -D ENV_PIN_SCL=34 + -D DISPLAY_CLASS=SSD1306Display +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + + + +<../examples/simple_sensor> +lib_deps = + ${MKE_s3.lib_deps} + ${esp32_ota.lib_deps} diff --git a/variants/mke_s3/target.cpp b/variants/mke_s3/target.cpp new file mode 100644 index 00000000000..ee3c1e804a1 --- /dev/null +++ b/variants/mke_s3/target.cpp @@ -0,0 +1,46 @@ +#include +#include "target.h" + +MKES3Board board; + +#if defined(P_LORA_SCLK) + static SPIClass spi; + RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); +#else + RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); +#endif + +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); + +#if ENV_INCLUDE_GPS + #include + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); + EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); +#else + EnvironmentSensorManager sensors; +#endif + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; + MomentaryButton user_btn(PIN_USER_BTN, 1000, true); +#endif + +bool radio_init() { + fallback_clock.begin(); + rtc_clock.begin(Wire); + +#if defined(P_LORA_SCLK) + return radio.std_init(&spi); +#else + return radio.std_init(); +#endif +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} + diff --git a/variants/mke_s3/target.h b/variants/mke_s3/target.h new file mode 100644 index 00000000000..8ceeaa978b6 --- /dev/null +++ b/variants/mke_s3/target.h @@ -0,0 +1,29 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#include +#include +#ifdef DISPLAY_CLASS + #include + #include +#endif + +extern MKES3Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); + +#include "PinChecks.h" From b195388a6effd1bfd205b9572f4a745dd7a70112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 17:26:59 +0200 Subject: [PATCH 26/44] repeater: 'get/set outpath' admin commands with persistent forced flood From the remote admin console a client can read or set the path the repeater uses for replies to it: flood, direct, clear (automatic learning) or an explicit list of 1-, 2- or 3-byte hop hashes. Hop lists are parsed with full hex validation. A forced flood is stored in the ACL and survives reboot, login and learned PATH messages. Direct and hop lists are replaced by the next flood login or PATH, as before. The ACL is saved about 5 s later. From PowerSaving-v17 00242269 and 4cb67d90. --- docs/cli_commands.md | 13 +++ examples/simple_repeater/MyMesh.cpp | 47 ++++++++-- examples/simple_repeater/MyMesh.h | 2 +- src/helpers/ClientACL.h | 2 +- src/helpers/OutPath.h | 131 ++++++++++++++++++++++++++++ test/test_outpath/test_outpath.cpp | 77 ++++++++++++++++ 6 files changed, 261 insertions(+), 11 deletions(-) create mode 100644 src/helpers/OutPath.h create mode 100644 test/test_outpath/test_outpath.cpp diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 27d30dba1d3..061d6aaecb6 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1200,3 +1200,16 @@ Ethernet support is available on RAK4631 boards with a RAK13800 (W5100S) Etherne ### Periodic reboot `get reboot.interval` / `set reboot.interval <0-255>`: hours since boot (0 disables). Repeater, Room Server and Sensor persist this setting. A due reboot waits for current TX and an active OTA session to finish. + +--- + +### Repeater return path (outpath) + +Remote admin console of a Repeater only; the command needs the requesting client, so it fails on the local serial console. + +- `get outpath` +- `set outpath flood|direct|clear` +- `set outpath ,,...`: 1-, 2- or 3-byte hop hashes, e.g. `set outpath 12,34` or `set outpath aabbcc,ddeeff`. + +The path applies to replies to this client. `flood` survives reboot, login and learned PATH messages; `direct` and a hop list are replaced when the client logs in again by flood or a new PATH arrives; `clear` returns to automatic path learning. Saved with the ACL about 5 s later. + diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index f063df6e4c1..0c33a3e6f3b 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -128,7 +128,7 @@ uint8_t MyMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secr } } - if (is_flood) { + if (is_flood && client->out_path_len != OUT_PATH_FORCE_FLOOD) { client->out_path_len = OUT_PATH_UNKNOWN; // need to rediscover out_path } @@ -595,9 +595,10 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m // a DIRECT login can reply via the stored out_path, as onPeerDataRecv() does for REQ ClientInfo* client = acl.getClient(sender.pub_key, PUB_KEY_SIZE); - bool have_out_path = client != NULL && client->out_path_len != OUT_PATH_UNKNOWN; + bool have_out_path = client != NULL && mesh::Packet::isValidPathLen(client->out_path_len); - auto route = mesh::chooseReplyRoute(packet->isRouteFlood(), reply_path_len != 0xFF, have_out_path); + const bool force_flood = client && client->out_path_len == OUT_PATH_FORCE_FLOOD; + auto route = mesh::chooseReplyRoute(packet->isRouteFlood(), !force_flood && reply_path_len != 0xFF, have_out_path); if (route == mesh::REPLY_ROUTE_PATH_RETURN) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response @@ -689,7 +690,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len); if (reply) { - if (client->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT + if (mesh::Packet::isValidPathLen(client->out_path_len)) { // we have an out_path, so send DIRECT sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); } else { sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); @@ -722,7 +723,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, mesh::Packet *ack = createAck(ack_hash); if (ack) { - if (client->out_path_len == OUT_PATH_UNKNOWN) { + if (!mesh::Packet::isValidPathLen(client->out_path_len)) { sendFloodReply(ack, TXT_ACK_DELAY, packet->getPathHashSize()); } else { sendDirect(ack, client->out_path, client->out_path_len, TXT_ACK_DELAY); @@ -736,7 +737,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, if (is_retry) { *reply = 0; } else { - handleCommand(sender_timestamp, command, reply); + handleCommand(sender_timestamp, command, reply, client); } int text_len = strlen(reply); if (text_len > 0) { @@ -750,7 +751,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len); if (reply) { - if (client->out_path_len == OUT_PATH_UNKNOWN) { + if (!mesh::Packet::isValidPathLen(client->out_path_len)) { sendFloodReply(reply, CLI_REPLY_DELAY_MILLIS, packet->getPathHashSize()); } else { sendDirect(reply, client->out_path, client->out_path_len, CLI_REPLY_DELAY_MILLIS); @@ -773,7 +774,7 @@ bool MyMesh::onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t auto client = acl.getClientByIdx(i); // store a copy of path, for sendDirect() - client->out_path_len = mesh::Packet::copyPath(client->out_path, path, path_len); + learnOutPath(client->out_path, client->out_path_len, path, path_len); client->last_activity = getRTCClock()->getCurrentTime(); } else { MESH_DEBUG_PRINTLN("onPeerPathRecv: invalid peer idx: %d", i); @@ -1195,7 +1196,7 @@ void MyMesh::clearStats() { ((SimpleMeshTables *)getTables())->resetStats(); } -void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { +void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply, ClientInfo* sender) { if (region_load_active) { if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation region_map = temp_map; // copy over the temp instance as new current map @@ -1272,6 +1273,34 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply Serial.printf("\n"); } reply[0] = 0; + } else if (strcmp(command, "get outpath") == 0 + || strcmp(command, "set outpath") == 0 + || strncmp(command, "set outpath ", 12) == 0) { + bool is_get = strncmp(command, "get ", 4) == 0; + if (sender == NULL) { + strcpy(reply, "Err - command needs remote client context"); + } else if (is_get) { + formatPathReply(sender->out_path, sender->out_path_len, reply, 160); + } else { + char* spec = command + 11; // length of "set outpath" + if (*spec == ' ') spec++; + + uint8_t path[MAX_PATH_SIZE]; + uint8_t path_len = OUT_PATH_UNKNOWN; + const char* err = NULL; + if (!parsePathCommand(spec, path, path_len, err)) { + strcpy(reply, err ? err : "Err - invalid path"); + } else { + if (path_len == OUT_PATH_UNKNOWN || path_len == OUT_PATH_FORCE_FLOOD) { + memset(sender->out_path, 0, sizeof(sender->out_path)); + sender->out_path_len = path_len; + } else { + sender->out_path_len = mesh::Packet::copyPath(sender->out_path, path, path_len); + } + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + formatPathReply(sender->out_path, sender->out_path_len, reply, 160); + } + } } else if (memcmp(command, "discover.neighbors", 18) == 0) { const char* sub = command + 18; while (*sub == ' ') sub++; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index dc89325861c..260a4154909 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -226,7 +226,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void saveIdentity(const mesh::LocalIdentity& new_id) override; void clearStats() override; - void handleCommand(uint32_t sender_timestamp, char* command, char* reply); + void handleCommand(uint32_t sender_timestamp, char* command, char* reply, ClientInfo* sender = nullptr); void loop(); #if defined(WITH_BRIDGE) diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h index 9938bc1e1f6..7be13363759 100644 --- a/src/helpers/ClientACL.h +++ b/src/helpers/ClientACL.h @@ -10,7 +10,7 @@ #define PERM_ACL_READ_WRITE 2 #define PERM_ACL_ADMIN 3 -#define OUT_PATH_UNKNOWN 0xFF +#include struct ClientInfo { mesh::Identity id; diff --git a/src/helpers/OutPath.h b/src/helpers/OutPath.h new file mode 100644 index 00000000000..2596d67a1fe --- /dev/null +++ b/src/helpers/OutPath.h @@ -0,0 +1,131 @@ +#pragma once +#include +#include +#include +#include +#define OUT_PATH_FORCE_FLOOD 0xFE +#define OUT_PATH_UNKNOWN 0xFF + +static char* trimOutPathSpaces(char* s) { + while (*s == ' ') s++; + char* end = s + strlen(s); + while (end > s && end[-1] == ' ') end--; + *end = 0; + return s; +} + +static bool parsePathCommandImpl(char* raw, uint8_t* out_path, uint8_t& out_path_len, const char*& err) { + if (raw == NULL || out_path == NULL) { + err = "Err - bad params"; + return false; + } + + char* spec = trimOutPathSpaces(raw); + if (*spec == 0) { + err = "Err - missing path"; + return false; + } + if (strcmp(spec, "clear") == 0 || strcmp(spec, "-") == 0 || strcmp(spec, "none") == 0) { + out_path_len = OUT_PATH_UNKNOWN; + return true; + } + if (strcmp(spec, "flood") == 0) { + out_path_len = OUT_PATH_FORCE_FLOOD; + return true; + } + if (strcmp(spec, "direct") == 0) { + out_path_len = 0; + return true; + } + + uint8_t hash_size = 0; + uint8_t hop_count = 0; + char* token = spec; + while (token && *token) { + char* comma = strchr(token, ','); + if (comma && comma[1] == 0) { err = "Err - missing hop"; return false; } + if (comma) *comma = 0; + token = trimOutPathSpaces(token); + + int hex_len = strlen(token); + if (!(hex_len == 2 || hex_len == 4 || hex_len == 6)) { + err = "Err - bad params"; + return false; + } + + for (int i = 0; i < hex_len; ++i) { + char c = token[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) { + err = "Err - bad hex"; + return false; + } + } + uint8_t hop_hash_size = (uint8_t)(hex_len / 2); + if (hash_size == 0) { + hash_size = hop_hash_size; + } else if (hash_size != hop_hash_size) { + err = "Err - bad params"; + return false; + } + + if (hop_count >= 63 || (hop_count + 1) * hash_size > MAX_PATH_SIZE) { + err = "Err - bad params"; + return false; + } + if (!mesh::Utils::fromHex(&out_path[hop_count * hash_size], hash_size, token)) { + err = "Err - bad hex"; + return false; + } + + hop_count++; + token = comma ? comma + 1 : NULL; + } + + if (hash_size == 0 || hop_count == 0) { + err = "Err - missing path"; + return false; + } + out_path_len = ((hash_size - 1) << 6) | (hop_count & 63); + return true; +} + +static void formatPathReply(const uint8_t* path, uint8_t path_len, char* out, size_t out_len) { + if (path_len == OUT_PATH_FORCE_FLOOD) { + snprintf(out, out_len, "> flood"); + return; + } + if (path_len == OUT_PATH_UNKNOWN) { + snprintf(out, out_len, "> unknown"); + return; + } + if (!mesh::Packet::isValidPathLen(path_len)) { + snprintf(out, out_len, "> invalid"); + return; + } + if ((path_len & 63) == 0) { + snprintf(out, out_len, "> direct"); + return; + } + + uint8_t hash_size = (path_len >> 6) + 1; + uint8_t hop_count = path_len & 63; + uint8_t byte_len = hop_count * hash_size; + char hex[(MAX_PATH_SIZE * 2) + 1]; + mesh::Utils::toHex(hex, path, byte_len); + snprintf(out, out_len, "> hs=%u hops=%u hex=%s", (uint32_t)hash_size, (uint32_t)hop_count, hex); +} + + +inline bool parsePathCommand(char* raw, uint8_t* out_path, uint8_t& out_path_len, const char*& err) { + if (!raw || !out_path) { err = "Err - bad params"; return false; } + uint8_t proposed[MAX_PATH_SIZE] = {}; + uint8_t proposed_len = OUT_PATH_UNKNOWN; + if (!parsePathCommandImpl(raw, proposed, proposed_len, err)) return false; + memcpy(out_path, proposed, MAX_PATH_SIZE); + out_path_len = proposed_len; + return true; +} + +inline void learnOutPath(uint8_t* stored, uint8_t& stored_len, const uint8_t* path, uint8_t len) { + if (stored_len != OUT_PATH_FORCE_FLOOD) stored_len = mesh::Packet::copyPath(stored, path, len); +} diff --git a/test/test_outpath/test_outpath.cpp b/test/test_outpath/test_outpath.cpp new file mode 100644 index 00000000000..c4879a5886c --- /dev/null +++ b/test/test_outpath/test_outpath.cpp @@ -0,0 +1,77 @@ +#include +#include +#include +#include +#include +#include +#include +// Only storage and key derivation are faked. ClientACL's real record format runs below. +class File { + std::shared_ptr> data;size_t offset=0; +public: + File(std::shared_ptr> d={}):data(d){} + explicit operator bool()const{return bool(data);} + size_t read(uint8_t* out,size_t n) {n=std::min(n,data->size()-offset);memcpy(out,data->data()+offset,n);offset+=n;return n;} + size_t write(const uint8_t* in,size_t n) {data->insert(data->end(),in,in+n);return n;} + void close(){} +}; +struct FakeFS { + std::map>> files; + void mkdir(const char*){} + bool exists(const char* key){return files.count(key)>0;} + void remove(const char* key){files.erase(key);} + File open(const char* key,const char* mode="r",bool=false) { + if (*mode=='w')files[key]=std::make_shared>(); + return File(files[key]); + } +}; +#define FILESYSTEM FakeFS +#include "../../src/helpers/ClientACL.cpp" +namespace mesh { +Identity::Identity(){memset(pub_key,0,sizeof(pub_key));} +LocalIdentity::LocalIdentity():Identity(){} +void LocalIdentity::calcSharedSecret(uint8_t* out,const uint8_t*)const{memset(out,0,PUB_KEY_SIZE);} +} +static bool parse(const char* text,uint8_t* path,uint8_t& len) { + std::vector input(text,text+strlen(text)+1);const char* err=nullptr; + return parsePathCommand(input.data(),path,len,err); +} +TEST(OutPath, ManualHashWidthsAndModes) { + uint8_t path[MAX_PATH_SIZE]={},len=0;char reply[160]; + for (const auto& spec: {std::string("aa,bb"),std::string("aabb,ccdd"),std::string("aabbcc,ddeeff")}) { + ASSERT_TRUE(parse(spec.c_str(),path,len));EXPECT_TRUE(mesh::Packet::isValidPathLen(len));EXPECT_EQ(2,len&63); + } + ASSERT_TRUE(parse(" flood ",path,len));EXPECT_EQ(OUT_PATH_FORCE_FLOOD,len); + formatPathReply(path,len,reply,sizeof(reply));EXPECT_STREQ("> flood",reply); + ASSERT_TRUE(parse("clear",path,len));EXPECT_EQ(OUT_PATH_UNKNOWN,len); + ASSERT_TRUE(parse("direct",path,len));EXPECT_EQ(0,len); +} +TEST(OutPath, InvalidPathDoesNotModifyExistingRoute) { + uint8_t path[MAX_PATH_SIZE],len=7;memset(path,0x55,sizeof(path)); + for (const char* spec:{"", "aa,", ",aa", "aa,,bb", "aa,bbbb", "zz", "a", "aabbccdd"}) { + EXPECT_FALSE(parse(spec,path,len))<out_path,client->out_path_len)); + } + acl.save(&fs); + ClientACL restored;restored.load(&fs,self);ASSERT_EQ(5,restored.getNumClients()); + for(int i=0;i<5;++i) { + EXPECT_EQ(acl.getClientByIdx(i)->out_path_len,restored.getClientByIdx(i)->out_path_len); + EXPECT_EQ(0,memcmp(acl.getClientByIdx(i)->out_path,restored.getClientByIdx(i)->out_path,MAX_PATH_SIZE)); + } +} +int main(int argc,char**argv) {testing::InitGoogleTest(&argc,argv);return RUN_ALL_TESTS();} From 23153efd82d46924264e23a9ae3d58485505052e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 18:42:06 +0200 Subject: [PATCH 27/44] variants: add Seeed Wio Tracker L2 Pro nRF52840 + SX1262 with TCA9535 expander, ADS1115, NV3031B LCD with GT911 touch, GNSS and audio. Repeater, room server and companion BLE/USB envs. getTouch() delegates to the variant's readTouch(), which checks for an actual touch before reading coordinates. From PowerSaving-v17 6c7b751e (upstream PR #3381). --- boards/seeed_wio_tracker_l2.json | 43 ++++ variants/wio-tracker-l2/GpsTap.h | 139 ++++++++++++ variants/wio-tracker-l2/README.md | 65 ++++++ variants/wio-tracker-l2/Sound.cpp | 157 +++++++++++++ variants/wio-tracker-l2/Sound.h | 19 ++ variants/wio-tracker-l2/WioTrackerL2Board.cpp | 204 +++++++++++++++++ variants/wio-tracker-l2/WioTrackerL2Board.h | 106 +++++++++ variants/wio-tracker-l2/WioTrackerL2Display.h | 214 ++++++++++++++++++ variants/wio-tracker-l2/platformio.ini | 134 +++++++++++ variants/wio-tracker-l2/target.cpp | 35 +++ variants/wio-tracker-l2/target.h | 30 +++ 11 files changed, 1146 insertions(+) create mode 100644 boards/seeed_wio_tracker_l2.json create mode 100644 variants/wio-tracker-l2/GpsTap.h create mode 100644 variants/wio-tracker-l2/README.md create mode 100644 variants/wio-tracker-l2/Sound.cpp create mode 100644 variants/wio-tracker-l2/Sound.h create mode 100644 variants/wio-tracker-l2/WioTrackerL2Board.cpp create mode 100644 variants/wio-tracker-l2/WioTrackerL2Board.h create mode 100644 variants/wio-tracker-l2/WioTrackerL2Display.h create mode 100644 variants/wio-tracker-l2/platformio.ini create mode 100644 variants/wio-tracker-l2/target.cpp create mode 100644 variants/wio-tracker-l2/target.h diff --git a/boards/seeed_wio_tracker_l2.json b/boards/seeed_wio_tracker_l2.json new file mode 100644 index 00000000000..706ed4c1701 --- /dev/null +++ b/boards/seeed_wio_tracker_l2.json @@ -0,0 +1,43 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "memory_type": "qio_opi", + "partitions": "default_16MB.csv" + }, + "core": "esp32", + "extra_flags": [ + "-DBOARD_HAS_PSRAM", + "-DARDUINO_USB_CDC_ON_BOOT=1", + "-DARDUINO_USB_MODE=1", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "psram_type": "opi", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "esp32s3" + }, + "connectivity": ["wifi", "bluetooth", "lora"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino"], + "name": "Seeed Wio Tracker L2 (16 MB flash, 8 MB OPI PSRAM)", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://www.seeedstudio.com/", + "vendor": "Seeed Studio" +} diff --git a/variants/wio-tracker-l2/GpsTap.h b/variants/wio-tracker-l2/GpsTap.h new file mode 100644 index 00000000000..020f5e9b185 --- /dev/null +++ b/variants/wio-tracker-l2/GpsTap.h @@ -0,0 +1,139 @@ +#pragma once + +#include + +// Transparent wrapper around the GNSS UART. MicroNMEA ignores GSV sentences, +// so this watches the byte stream for them and keeps a running count of +// satellites in view, strong satellites and best SNR, which separates RF +// faults from a receiver that only lacks sky view. It also records the +// fix flags straight from GGA/RMC so a parser problem upstream can't hide a +// real fix, and can hand every complete line to a logger (SD card). +class GpsTapStream : public Stream { + Stream& _src; + char _line[120]; + uint8_t _len = 0; + struct Talker { char id[3]; uint8_t in_view; uint8_t strong; uint8_t strong_acc; unsigned long at; }; + Talker _talkers[6] = {}; + int _best_snr = 0; + unsigned long _best_at = 0; + unsigned long _last_gsv = 0; + int _gga_fix = -1; // GGA fix quality (0 = no fix) + int _gga_used = 0; // GGA satellites used + char _rmc_status = '?'; // RMC status: A = valid, V = void + unsigned long _last_fix_sentence = 0; + + static const unsigned long FRESH_MS = 6000; + static const int STRONG_DB = 30; + + // pointer to comma-separated field n (0 = sentence id), NULL if absent + static const char* field(const char* s, int n) { + const char* p = s; + for (int i = 0; i < n; i++) { + p = strchr(p, ','); + if (p == NULL) return NULL; + p++; + } + return p; + } + + void parseGsv(const char* s) { + // $GPGSV,total,msg,in_view,prn,elev,az,snr,...*cs + char talker[3] = {s[1], s[2], 0}; + const char* f; + int total = (f = field(s, 1)) ? atoi(f) : 0; + int msg = (f = field(s, 2)) ? atoi(f) : 0; + int in_view = (f = field(s, 3)) ? atoi(f) : -1; + if (in_view < 0) return; + + int strong_here = 0; + for (int k = 0; k < 4; k++) { + const char* snr = field(s, 7 + 4 * k); + if (snr == NULL || *snr == ',' || *snr == '*' || *snr == 0) continue; + int v = atoi(snr); + if (v >= STRONG_DB) strong_here++; + if (v > _best_snr || millis() - _best_at > FRESH_MS) { _best_snr = v; _best_at = millis(); } + } + + int slot = -1; + for (int i = 0; i < 6; i++) { + if (strcmp(_talkers[i].id, talker) == 0) { slot = i; break; } + if (slot < 0 && _talkers[i].id[0] == 0) slot = i; + } + if (slot < 0) return; + Talker& t = _talkers[slot]; + memcpy(t.id, talker, 3); + t.in_view = (uint8_t) in_view; + if (msg <= 1) t.strong_acc = 0; + t.strong_acc += strong_here; + if (msg >= total) t.strong = t.strong_acc; + t.at = millis(); + _last_gsv = millis(); + } + + void parseFixFlags(const char* s) { + const char* type = s + 3; + if (strncmp(type, "GGA", 3) == 0) { + const char* f; + _gga_fix = (f = field(s, 6)) && *f != ',' ? atoi(f) : 0; + _gga_used = (f = field(s, 7)) && *f != ',' ? atoi(f) : 0; + _last_fix_sentence = millis(); + } else if (strncmp(type, "RMC", 3) == 0) { + const char* f = field(s, 2); + _rmc_status = (f != NULL && *f != ',' && *f != 0) ? *f : '?'; + _last_fix_sentence = millis(); + } + } + + void feed(int c) { + if (echo) Serial.write((uint8_t) c); + if (c == '\n' || c == '\r') { + if (_len > 6 && _line[0] == '$') { + _line[_len] = 0; + if (on_line != NULL) on_line(_line); + if (strncmp(_line + 3, "GSV", 3) == 0) parseGsv(_line); + else parseFixFlags(_line); + } + _len = 0; + } else if (_len < sizeof(_line) - 1) { + _line[_len++] = (char) c; + } + } + +public: + bool echo = false; // mirror raw NMEA to USB Serial + void (*on_line)(const char* line) = NULL; // complete-sentence hook (e.g. SD log) + + explicit GpsTapStream(Stream& src) : _src(src) {} + + int satsInView() const { + int n = 0; + for (int i = 0; i < 6; i++) { + if (_talkers[i].id[0] != 0 && millis() - _talkers[i].at < FRESH_MS) n += _talkers[i].in_view; + } + return n; + } + int strongSats() const { + int n = 0; + for (int i = 0; i < 6; i++) { + if (_talkers[i].id[0] != 0 && millis() - _talkers[i].at < FRESH_MS) n += _talkers[i].strong; + } + return n; + } + int bestSnr() const { return millis() - _best_at < FRESH_MS ? _best_snr : 0; } + bool streaming() const { return millis() - _last_gsv < FRESH_MS; } + int ggaFix() const { return _gga_fix; } + int ggaUsed() const { return _gga_used; } + char rmcStatus() const { return _rmc_status; } + + // Stream interface: pass-through with a tap on read() + int available() override { return _src.available(); } + int peek() override { return _src.peek(); } + int read() override { + int c = _src.read(); + if (c >= 0) feed(c); + return c; + } + void flush() override { _src.flush(); } + size_t write(uint8_t b) override { return _src.write(b); } + size_t write(const uint8_t* buf, size_t n) override { return _src.write(buf, n); } +}; diff --git a/variants/wio-tracker-l2/README.md b/variants/wio-tracker-l2/README.md new file mode 100644 index 00000000000..26d1e4a9c9f --- /dev/null +++ b/variants/wio-tracker-l2/README.md @@ -0,0 +1,65 @@ +# MeshCore for Seeed Wio Tracker L2 Pro + +MeshCore port for the **Seeed Studio Wio Tracker L2 Pro** (pre-release hardware; +pin map may change on production units). + +## Hardware + +| Component | Detail | +|-----------|--------| +| MCU module | Wio-S3: ESP32-S3, 16 MB flash (QIO), 8 MB PSRAM (OPI) | +| LoRa | SX1262, 862-930 MHz (SCK 4, MISO 5, MOSI 6, CS 21, RST 7, BUSY 8, DIO1 9, DIO2 = RF switch, TCXO 1.8 V) | +| GNSS | Quectel L76K (GPS/BeiDou/GLONASS/QZSS), UART: module TX to GPIO 18, module RX from GPIO 17, 9600 baud | +| Display | 3.2" 320x240 NV3031B on quad-SPI (SCLK 42, IO0-3 = 41/40/39/38, CS 46), 75 MHz | +| Touch | GT911 capacitive, I2C 0x5D | +| Backlight | LP5814 LED driver, I2C 0x2C | +| I/O expander | TCA9535, I2C 0x21 - gates power/reset for GNSS, LCD, touch, SD, speaker amp, battery ADC, Grove | +| Battery ADC | ADS1115, I2C 0x48, AIN0 through x2 divider | +| USB-C detect | AW35615 CC controller, I2C 0x22 (charge detection) | +| Audio | ES8311 codec (speaker, alert tones) + ES7243E mic ADC (unused) on I2S | +| I2C bus | SDA 47, SCL 48 | +| Buttons | User/Boot = GPIO 0; Wake/lock button on expander P00 | +| SD card | SDIO 1-bit: CLK 2, CMD 3, D0 1 - offline map tiles, logs | + +## Firmware flavors + +| Env | What it does | +|-----|--------------| +| `Wio_Tracker_L2_companion_radio_ble` | **Phone companion.** Standard display UI, MeshCore app over BLE (pairing PIN `123456`), on-screen Bluetooth toggle page. | +| `Wio_Tracker_L2_companion_radio_usb` | Standard display companion over USB-C serial. | +| `Wio_Tracker_L2_repeater` | Standalone mesh repeater. | +| `Wio_Tracker_L2_room_server` | Standalone room/BBS server. | + +## Build & flash + +```bash +# from the MeshCore repo root +pio run -e Wio_Tracker_L2_companion_radio_ble + +# bootloader mode if needed: hold User/Boot, tap RST, release - then: +pio run -e Wio_Tracker_L2_companion_radio_ble -t upload +``` + +The board enumerates as a native USB-CDC port (auto-reset via 1200-bps touch +is enabled). For the BLE companion build, pair from the MeshCore app with the +PIN shown on the device screen. + +## Port notes + +- **Everything hangs off the TCA9535 expander.** `WioTrackerL2Board::begin()` + replays Seeed's power-up sequence; order and delays matter (especially the + 500 ms LCD reset settle). If the expander probe fails, the firmware still + runs but GPS/display/battery reads are dead. +- **Battery** is read via the ADS1115 at +/-4.096 V FSR and mapped through a + LiPo discharge curve. +- **GNSS**: GPS+BeiDou+GLONASS are enabled at start (`$PCAS04,7`). A watchdog + resets the module if it tracks 4+ strong satellites for minutes without + producing a fix. Raw NMEA can be logged to SD for diagnosis. +- **Power**: CPU drops to 80 MHz while the screen sleeps; the speaker amp and + I2S clocks run only while a tone plays; the GNSS and Grove rails are gated; + idle Bluetooth switches to slow advertising. +- **Display orientation**: `offset_rotation=1` (landscape). If a unit shows + the UI upside-down, use 3 in `WioTrackerL2Display.h`. +- Pin map source: `meshtastic/firmware` [PR #10909](https://github.com/meshtastic/firmware/pull/10909), + since merged as [`variants/esp32s3/seeed_wio_tracker_L2`](https://github.com/meshtastic/firmware/tree/master/variants/esp32s3/seeed_wio_tracker_L2), + and the `meshtastic/device-ui` [`wio-l2` branch](https://github.com/meshtastic/device-ui/tree/wio-l2). diff --git a/variants/wio-tracker-l2/Sound.cpp b/variants/wio-tracker-l2/Sound.cpp new file mode 100644 index 00000000000..b84f4a77195 --- /dev/null +++ b/variants/wio-tracker-l2/Sound.cpp @@ -0,0 +1,157 @@ +#include "Sound.h" + +#include + +#include +#include +#include +#include +#include "AudioBoard.h" // pschatzmann/arduino-audio-driver +#include +#include + +// Wio-S3 audio wiring: ES8311 codec on I2C (0x18), I2S MCLK 10 / BCK 11 / +// WS 12 / DOUT 16; speaker amp power (PA EN) is raised by the board init. +#define SND_I2S_MCLK 10 +#define SND_I2S_BCK 11 +#define SND_I2S_WS 12 +#define SND_I2S_DOUT 16 +#define SND_SAMPLE_RATE 44100 + +static DriverPins snd_pins; +static AudioBoard snd_codec(AudioDriverES8311, snd_pins); +static bool snd_ready = false; +static void (*snd_amp_ctl)(bool) = NULL; // board hook: speaker amp power + +void soundSetAmpControl(void (*fn)(bool on)) { snd_amp_ctl = fn; } + +void soundInit() { + // i2s_driver_install crashes inside IDF's cleanup if its DMA allocation + // fails (LoadProhibited boot loop) - refuse to try without clear headroom + if (heap_caps_get_free_size(MALLOC_CAP_DMA) < 60000) { + MESH_DEBUG_PRINTLN("sound: low DMA heap - sound disabled this boot"); + return; + } + + snd_pins.addI2C(PinFunction::CODEC, Wire); + snd_pins.addI2S(PinFunction::CODEC, SND_I2S_MCLK, SND_I2S_BCK, SND_I2S_WS, SND_I2S_DOUT, -1); + + CodecConfig cfg; + cfg.input_device = ADC_INPUT_NONE; + cfg.output_device = DAC_OUTPUT_ALL; + cfg.i2s.bits = BIT_LENGTH_16BITS; + cfg.i2s.rate = RATE_44K; + if (!snd_codec.begin(cfg)) { + MESH_DEBUG_PRINTLN("sound: ES8311 init failed - sound disabled"); + return; + } + snd_codec.setVolume(70); + + i2s_config_t i2s_cfg = {}; + i2s_cfg.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX); + i2s_cfg.sample_rate = SND_SAMPLE_RATE; + i2s_cfg.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT; + i2s_cfg.channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT; + i2s_cfg.communication_format = I2S_COMM_FORMAT_STAND_I2S; + i2s_cfg.intr_alloc_flags = 0; + i2s_cfg.dma_buf_count = 2; // keep the DMA footprint minimal + i2s_cfg.dma_buf_len = 256; + i2s_cfg.tx_desc_auto_clear = true; + if (i2s_driver_install(I2S_NUM_0, &i2s_cfg, 0, NULL) != ESP_OK) { + MESH_DEBUG_PRINTLN("sound: i2s driver install failed"); + return; + } + i2s_pin_config_t pin_cfg = {}; + pin_cfg.mck_io_num = SND_I2S_MCLK; + pin_cfg.bck_io_num = SND_I2S_BCK; + pin_cfg.ws_io_num = SND_I2S_WS; + pin_cfg.data_out_num = SND_I2S_DOUT; + pin_cfg.data_in_num = I2S_PIN_NO_CHANGE; + if (i2s_set_pin(I2S_NUM_0, &pin_cfg) != ESP_OK) { + MESH_DEBUG_PRINTLN("sound: i2s pin config failed"); + return; + } + i2s_stop(I2S_NUM_0); // clocks off until a tone plays (GNSS-adjacent EMI, battery) + snd_ready = true; + MESH_DEBUG_PRINTLN("sound: ES8311 + I2S ready"); +} + +bool soundReady() { return snd_ready; } + +void soundBeep(int freq_hz, int duration_ms) { + if (!snd_ready) return; + if (snd_amp_ctl) snd_amp_ctl(true); + i2s_start(I2S_NUM_0); + delay(3); // amp settle before the first samples + const int total = SND_SAMPLE_RATE * duration_ms / 1000; + const float amp = 6000.0f; // gentle level; codec volume does the rest + static int16_t buf[256 * 2]; // 256 stereo frames per chunk + + int written_frames = 0; + while (written_frames < total) { + int chunk = total - written_frames; + if (chunk > 256) chunk = 256; + for (int i = 0; i < chunk; i++) { + float t = (float)(written_frames + i) / SND_SAMPLE_RATE; + // short attack/decay envelope so the beep doesn't click + float env = 1.0f; + int pos = written_frames + i; + if (pos < 220) env = pos / 220.0f; + else if (pos > total - 220) env = (total - pos) / 220.0f; + int16_t s = (int16_t)(amp * env * sinf(2.0f * (float)M_PI * freq_hz * t)); + buf[i * 2] = s; + buf[i * 2 + 1] = s; + } + size_t written_bytes = 0; + i2s_write(I2S_NUM_0, buf, chunk * 2 * sizeof(int16_t), &written_bytes, portMAX_DELAY); + written_frames += chunk; + } + i2s_zero_dma_buffer(I2S_NUM_0); + i2s_stop(I2S_NUM_0); + if (snd_amp_ctl) snd_amp_ctl(false); +} + +// selectable alert melodies; {0,0} terminates a style early +struct ToneNote { uint16_t freq; uint16_t ms; }; +static const ToneNote TONE_STYLES[SOUND_TONE_STYLES][5] = { + {{880, 90}, {1175, 120}, {0, 0}}, // Classic + {{1400, 40}, {1800, 40}, {2200, 60}, {0, 0}}, // Chirp + {{1319, 140}, {988, 180}, {0, 0}}, // Ding dong + {{1568, 45}, {1245, 45}, {1568, 45}, {1245, 90}, {0, 0}}, // Trill + {{659, 70}, {880, 70}, {1175, 70}, {1568, 110}, {0, 0}}, // Rise + {{1047, 120}, {523, 120}, {1047, 120}, {0, 0}}, // Alarm +}; +static int tone_style = 0; + +void soundLoadTonePref() { + File f = SPIFFS.open("/tone", "r"); + if (f) { + int v = f.parseInt(); + f.close(); + if (v >= 0 && v < SOUND_TONE_STYLES) tone_style = v; + } +} + +void soundSetToneStyle(int style) { + if (style < 0 || style >= SOUND_TONE_STYLES) return; + tone_style = style; + File f = SPIFFS.open("/tone", "w"); + if (f) { + f.print(style); + f.close(); + } +} + +int soundGetToneStyle() { return tone_style; } + +void soundMessageTone() { + const ToneNote* n = TONE_STYLES[tone_style]; + for (int i = 0; i < 5 && n[i].freq != 0; i++) soundBeep(n[i].freq, n[i].ms); +} + +void soundChannelTone() { + // softer cousin of the chosen alert: first note, dropped a fourth + soundBeep(TONE_STYLES[tone_style][0].freq * 3 / 4, 100); +} + +void soundAckTone() { soundBeep(1568, 50); } diff --git a/variants/wio-tracker-l2/Sound.h b/variants/wio-tracker-l2/Sound.h new file mode 100644 index 00000000000..9e5f633c0bd --- /dev/null +++ b/variants/wio-tracker-l2/Sound.h @@ -0,0 +1,19 @@ +#pragma once + +// Board audio backend for the Wio Tracker L2 Pro (ES8311 codec + speaker). +// UI code includes "Sound.h" and gets whatever the variant provides; all +// functions are safe no-ops if the codec failed to initialize. + +#define SOUND_TONE_STYLES 6 // Classic, Chirp, Ding dong, Trill, Rise, Alarm + +void soundInit(); // call once after Wire is up (board.begin done) +void soundSetAmpControl(void (*fn)(bool on)); // board hook to gate the speaker amp power +bool soundReady(); +void soundBeep(int freq_hz, int duration_ms); // short blocking tone +void soundMessageTone(); // incoming DM (plays the selected alert style) +void soundChannelTone(); // incoming channel message (softer variant) +void soundAckTone(); // delivery ack / confirm + +void soundLoadTonePref(); // read /tone from SPIFFS (safe before soundInit) +void soundSetToneStyle(int style); // select + persist an alert style +int soundGetToneStyle(); diff --git a/variants/wio-tracker-l2/WioTrackerL2Board.cpp b/variants/wio-tracker-l2/WioTrackerL2Board.cpp new file mode 100644 index 00000000000..edeaacce52e --- /dev/null +++ b/variants/wio-tracker-l2/WioTrackerL2Board.cpp @@ -0,0 +1,204 @@ +#include "WioTrackerL2Board.h" + +// TCA9535 register map +static const uint8_t TCA_REG_OUTPUT0 = 0x02; +static const uint8_t TCA_REG_OUTPUT1 = 0x03; +static const uint8_t TCA_REG_CONFIG0 = 0x06; // 1 = input, 0 = output +static const uint8_t TCA_REG_CONFIG1 = 0x07; + +// ADS1115 registers / config bits +static const uint8_t ADS_REG_CONVERSION = 0x00; +static const uint8_t ADS_REG_CONFIG = 0x01; +// OS=1 (start single shot) | MUX=100 (AIN0 vs GND) | PGA=001 (+/-4.096V) +// | MODE=1 (single shot) | DR=100 (128 SPS) | COMP_QUE=11 (disabled) +static const uint16_t ADS_CFG_BATT = 0x8000 | 0x4000 | 0x0200 | 0x0100 | 0x0080 | 0x0003; +// +/-4.096V FSR -> 125uV/LSB; battery is behind a x2 divider -> 0.25 mV/LSB +static const float ADS_MV_PER_LSB = 0.125f * 2.0f; + +bool WioTrackerL2Board::expWriteReg(uint8_t reg, uint8_t val) { + Wire.beginTransmission(TCA9535_ADDR); + Wire.write(reg); + Wire.write(val); + return Wire.endTransmission() == 0; +} + +void WioTrackerL2Board::expSetOutput(uint8_t pin, bool initial_level) { + uint8_t port = pin >> 3, bit = pin & 7; + // set desired level first so the pin doesn't glitch when direction flips + if (initial_level) out_shadow[port] |= (1 << bit); + else out_shadow[port] &= ~(1 << bit); + expWriteReg(port ? TCA_REG_OUTPUT1 : TCA_REG_OUTPUT0, out_shadow[port]); + + cfg_shadow[port] &= ~(1 << bit); // 0 = output + expWriteReg(port ? TCA_REG_CONFIG1 : TCA_REG_CONFIG0, cfg_shadow[port]); +} + +void WioTrackerL2Board::expSetInput(uint8_t pin) { + uint8_t port = pin >> 3, bit = pin & 7; + cfg_shadow[port] |= (1 << bit); + expWriteReg(port ? TCA_REG_CONFIG1 : TCA_REG_CONFIG0, cfg_shadow[port]); +} + +void WioTrackerL2Board::expWritePin(uint8_t pin, bool level) { + uint8_t port = pin >> 3, bit = pin & 7; + if (level) out_shadow[port] |= (1 << bit); + else out_shadow[port] &= ~(1 << bit); + expWriteReg(port ? TCA_REG_OUTPUT1 : TCA_REG_OUTPUT0, out_shadow[port]); +} + +// Power-up sequence mirrors Seeed's reference firmware for this board +// (order and delays matter, especially the LCD reset settle time). +bool WioTrackerL2Board::initExpander() { + Wire.beginTransmission(TCA9535_ADDR); + if (Wire.endTransmission() != 0) { + return false; // expander not responding + } + + expSetInput(EXP_PIN_WAKE_BTN); + expSetInput(EXP_PIN_I2C_IRQ); + expSetInput(EXP_PIN_SD_DETECT); + + expSetOutput(EXP_PIN_OTG_EN, LOW); + delay(10); + expSetOutput(EXP_PIN_PA_EN, LOW); // amp only powered while a tone plays + delay(10); + expSetOutput(EXP_PIN_TF_EN, HIGH); + delay(10); + expSetOutput(EXP_PIN_BAT_ADC_EN, HIGH); + delay(10); + expSetOutput(EXP_PIN_GNSS_EN, HIGH); + delay(10); + // GNSS reset is active HIGH; hold 10ms then release LOW so the L76K runs + expSetOutput(EXP_PIN_GNSS_RST, HIGH); + delay(10); + expWritePin(EXP_PIN_GNSS_RST, LOW); + // user LED is driven active-low through the expander; idle level HIGH = off + expSetOutput(EXP_PIN_USER_LED, HIGH); + delay(10); + expSetOutput(EXP_PIN_GROVE_EN, HIGH); + delay(10); + + expSetOutput(EXP_PIN_LCD_EN, HIGH); + delay(50); + expSetOutput(EXP_PIN_LCD_RST, HIGH); + delay(5); + expWritePin(EXP_PIN_LCD_RST, LOW); + delay(10); + expWritePin(EXP_PIN_LCD_RST, HIGH); + delay(500); // NV3031B needs a long settle after reset before init commands + expSetOutput(EXP_PIN_LCD_CS, HIGH); + delay(10); + + // GT911 touch reset sequence: INT low during reset selects I2C addr 0x5D + expSetOutput(EXP_PIN_TP_RST, LOW); + expSetOutput(EXP_PIN_TP_INT, LOW); + delay(10); + expWritePin(EXP_PIN_TP_RST, HIGH); + delay(60); + + // capture WAKE button idle level (polarity unknown on alpha hardware) + int inputs = expReadInputs(); + wake_btn_baseline = inputs >= 0 ? (uint8_t)(inputs & 1) : 0; + + return true; +} + +void WioTrackerL2Board::begin() { + // GNSS UART: NMEA arrives at ~500 B/s and the main loop can stall for + // hundreds of ms on SD tile decodes; the 256-byte default overflowed + Serial1.setRxBufferSize(1024); + + uint32_t t0 = millis(); + while (!Serial && millis() - t0 < 3000) { delay(50); } + MESH_DEBUG_PRINTLN("WioTrackerL2Board: ESP32Board init"); + + ESP32Board::begin(); // starts Wire on PIN_BOARD_SDA/PIN_BOARD_SCL (47/48) + + pinMode(PIN_USER_BTN, INPUT_PULLUP); + pinMode(P_LORA_MISO, INPUT_PULLUP); + + MESH_DEBUG_PRINTLN("WioTrackerL2Board: TCA9535 expander init"); + expander_ok = initExpander(); + if (!expander_ok) { + Serial.println("ERROR: TCA9535 IO expander not found - peripherals unpowered!"); + } + + Wire.beginTransmission(0x22); + aw_ok = Wire.endTransmission() == 0; + MESH_DEBUG_PRINTLN("WioTrackerL2Board: init done"); + + esp_reset_reason_t reason = esp_reset_reason(); + if (reason == ESP_RST_DEEPSLEEP) { + long wakeup_source = esp_sleep_get_ext1_wakeup_status(); + if (wakeup_source & (1 << P_LORA_DIO_1)) { + startup_reason = BD_STARTUP_RX_PACKET; // LoRa packet woke us from deep sleep + } + rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); + rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + } +} + +void WioTrackerL2Board::setLed(bool on) { + // P10 doubles as GNSS wakeup and idles HIGH; blink = brief LOW pulses so + // the GPS never sees a sustained low level + if (expander_ok) { + expWritePin(EXP_PIN_USER_LED, !on); + } +} + +int WioTrackerL2Board::expReadInputs() { + Wire.beginTransmission(TCA9535_ADDR); + Wire.write((uint8_t)0x00); // input port 0 register + if (Wire.endTransmission() != 0) return -1; + if (Wire.requestFrom((int)TCA9535_ADDR, 2) != 2) return -1; + int lo = Wire.read(); + int hi = Wire.read(); + return lo | (hi << 8); +} + +bool WioTrackerL2Board::isExternalPowered() { + if (!aw_ok) return false; + Wire.beginTransmission(0x22); + Wire.write((uint8_t)0x40); // STATUS0 + if (Wire.endTransmission() != 0) return false; + if (Wire.requestFrom(0x22, 1) != 1) return false; + return (Wire.read() & 0x80) != 0; // VBUSOK bit +} + +bool WioTrackerL2Board::readWakeButton() { + if (!expander_ok) return false; + int v = expReadInputs(); + if (v < 0) return false; + return ((uint8_t)(v & 1)) != wake_btn_baseline; +} + +int16_t WioTrackerL2Board::adsReadRaw() { + Wire.beginTransmission(ADS1115_ADDR); + Wire.write(ADS_REG_CONFIG); + Wire.write((uint8_t)(ADS_CFG_BATT >> 8)); + Wire.write((uint8_t)(ADS_CFG_BATT & 0xFF)); + if (Wire.endTransmission() != 0) return -1; + + delay(10); // 128 SPS -> ~8ms conversion time + + Wire.beginTransmission(ADS1115_ADDR); + Wire.write(ADS_REG_CONVERSION); + if (Wire.endTransmission() != 0) return -1; + if (Wire.requestFrom((int)ADS1115_ADDR, 2) != 2) return -1; + + // sequence the two reads explicitly: operand evaluation order of <<| + // is unspecified and each read dequeues a byte + uint8_t hi = Wire.read(); + uint8_t lo = Wire.read(); + int16_t raw = ((int16_t)hi << 8) | lo; + return raw < 0 ? 0 : raw; +} + +uint16_t WioTrackerL2Board::getBattMilliVolts() { + if (!expander_ok) return 0; // BAT_ADC_EN rail never came up + + int16_t raw = adsReadRaw(); + if (raw < 0) return 0; + + return (uint16_t)(raw * ADS_MV_PER_LSB); +} diff --git a/variants/wio-tracker-l2/WioTrackerL2Board.h b/variants/wio-tracker-l2/WioTrackerL2Board.h new file mode 100644 index 00000000000..c5644e2f204 --- /dev/null +++ b/variants/wio-tracker-l2/WioTrackerL2Board.h @@ -0,0 +1,106 @@ +#pragma once + +#include +#include +#include "helpers/ESP32Board.h" + +// --------------------------------------------------------------------------- +// Seeed Wio Tracker L2 Pro (Wio-S3 module: ESP32-S3 + SX1262) +// +// Nearly all peripheral power rails and reset lines are behind a TCA9535 +// 16-bit I2C IO expander at address 0x21. Nothing (GNSS, LCD, touch, SD, +// battery ADC, speaker amp) works until the expander is configured, so +// begin() must run before any display / GPS / sensor init. +// +// Battery voltage is read through an ADS1115 16-bit I2C ADC at 0x48, +// channel AIN0, behind a x2 resistive divider (rail gated by expander). +// --------------------------------------------------------------------------- + +#define TCA9535_ADDR 0x21 +#define ADS1115_ADDR 0x48 + +// TCA9535 pin numbering: 0..7 = port 0 (P00..P07), 8..15 = port 1 (P10..P17) +#define EXP_PIN_WAKE_BTN 0 // input - side WAKE button +#define EXP_PIN_I2C_IRQ 1 // input - shared I2C IRQ +#define EXP_PIN_SD_DETECT 2 // input - microSD card detect +#define EXP_PIN_TP_INT 3 // output - touch panel interrupt (driven for reset seq) +#define EXP_PIN_LCD_CS 4 // output - LCD chip select (idle high) +#define EXP_PIN_LCD_EN 5 // output - LCD power enable +#define EXP_PIN_LCD_RST 6 // output - LCD reset +#define EXP_PIN_GROVE_EN 7 // output - Grove port power +#define EXP_PIN_TP_RST 8 // output - touch panel reset +#define EXP_PIN_GNSS_RST 9 // output - GNSS reset (active HIGH) +#define EXP_PIN_USER_LED 10 // output - mesh/user LED (also GNSS wakeup) +#define EXP_PIN_OTG_EN 11 // output - USB OTG power +#define EXP_PIN_PA_EN 12 // output - speaker amp power +#define EXP_PIN_GNSS_EN 13 // output - GNSS power +#define EXP_PIN_TF_EN 14 // output - microSD power +#define EXP_PIN_BAT_ADC_EN 15 // output - battery ADC divider enable + +class WioTrackerL2Board : public ESP32Board { +public: + void begin(); + + uint16_t getBattMilliVolts() override; + + const char* getManufacturerName() const override { + return "Seeed Wio Tracker L2 Pro"; + } + + void onBeforeTransmit() override { setLed(true); } + void onAfterTransmit() override { setLed(false); } + + // Mesh/user LED lives on the IO expander, not a GPIO + void setLed(bool on); + + // speaker amplifier power (expander P12); off when idle to keep the GNSS + // antenna away from class-D switching noise and to save battery + void setSpeakerAmp(bool on) { expWritePin(EXP_PIN_PA_EN, on); } + + // hardware reset pulse to the L76K GNSS (active HIGH on this board); the + // module keeps almanac/ephemeris across it, so this is a warm restart + // GNSS rail: off saves the whole receiver when GPS is disabled; on re-runs + // the power-up reset so the module comes back cleanly + void setGnssPower(bool on) { + if (on) { + expWritePin(EXP_PIN_GNSS_EN, HIGH); + delay(10); + gnssReset(); + } else { + expWritePin(EXP_PIN_GNSS_EN, LOW); + } + } + // Grove expansion port rail (nothing on-board depends on it) + void setGrovePower(bool on) { expWritePin(EXP_PIN_GROVE_EN, on); } + + void gnssReset() { + expWritePin(EXP_PIN_GNSS_RST, HIGH); + delay(10); + expWritePin(EXP_PIN_GNSS_RST, LOW); + } + + // WAKE button on expander P00: pressed = level differs from boot baseline + bool readWakeButton(); + + // VBUS presence via the AW35615 USB-C controller (I2C 0x22) + bool isExternalPowered() override; + + bool expanderOK() const { return expander_ok; } + +private: + uint8_t out_shadow[2] = { 0xFF, 0xFF }; // TCA9535 output regs default high + uint8_t cfg_shadow[2] = { 0xFF, 0xFF }; // 1 = input (power-on default) + bool expander_ok = false; + bool aw_ok = false; // AW35615 USB-C controller responded at probe + uint8_t wake_btn_baseline = 0; // idle level of P00, captured at init + + int expReadInputs(); // 16-bit input register pair, -1 on error + + bool expWriteReg(uint8_t reg, uint8_t val); + void expSetOutput(uint8_t pin, bool initial_level); + void expSetInput(uint8_t pin); + void expWritePin(uint8_t pin, bool level); + bool initExpander(); + + int16_t adsReadRaw(); +}; diff --git a/variants/wio-tracker-l2/WioTrackerL2Display.h b/variants/wio-tracker-l2/WioTrackerL2Display.h new file mode 100644 index 00000000000..f48332b4695 --- /dev/null +++ b/variants/wio-tracker-l2/WioTrackerL2Display.h @@ -0,0 +1,214 @@ +#pragma once + +#include +#include + +#define LGFX_USE_V1 +#include + +// --------------------------------------------------------------------------- +// Wio Tracker L2 Pro display stack: +// - NV3031B 320x240 panel on quad-SPI (SCLK 42, IO0-3 = 41/40/39/38, CS 46) +// - GT911 capacitive touch on I2C 0x5D (SDA 47 / SCL 48) +// - LP5814 4-channel LED driver on I2C 0x2C used as backlight +// Panel power/reset lines are on the TCA9535 expander and are already +// sequenced by WioTrackerL2Board::begin() before this driver initializes. +// --------------------------------------------------------------------------- + +#ifndef L2_SPI_FREQUENCY + #define L2_SPI_FREQUENCY 75000000 +#endif + +#define LP5814_I2C_ADDR 0x2C + +// LP5814 used as backlight controller (all 4 channels in parallel) +class WioTrackerL2Backlight : public lgfx::v1::ILight { + static constexpr uint8_t REG_DEVICE_CONFIG0 = 0x00; + static constexpr uint8_t REG_MAX_CURRENT = 0x01; + static constexpr uint8_t REG_ENABLE_CONTROL = 0x02; + static constexpr uint8_t REG_DIM_MODE = 0x04; + static constexpr uint8_t REG_ENGINE_MODE = 0x05; + static constexpr uint8_t REG_UPDATE = 0x0F; + static constexpr uint8_t REG_LED0_DC = 0x14; + static constexpr uint8_t REG_LED0_PWM = 0x18; + + uint8_t _brightness = 153; // 60% + + void writeReg(uint8_t reg, uint8_t value) { + Wire.beginTransmission(LP5814_I2C_ADDR); + Wire.write(reg); + Wire.write(value); + Wire.endTransmission(); + } + +public: + bool init(uint8_t brightness) override { + Wire.beginTransmission(LP5814_I2C_ADDR); + if (Wire.endTransmission() != 0) { + return false; // LP5814 not found + } + + writeReg(REG_DEVICE_CONFIG0, 0x01); // chip enable + writeReg(REG_MAX_CURRENT, 0x01); // 51 mA max current + writeReg(REG_ENABLE_CONTROL, 0x00); // outputs off while configuring + writeReg(REG_DIM_MODE, 0x4E); + writeReg(REG_ENGINE_MODE, 0xF0); + for (uint8_t i = 0; i < 4; i++) { + writeReg(REG_LED0_DC + i, 200); + } + writeReg(REG_ENABLE_CONTROL, 0x0F); // enable all 4 channels + writeReg(REG_UPDATE, 0x55); // latch (LP5814 requires 0x55) + delay(5); + + setBrightness(brightness); + return true; + } + + void setBrightness(uint8_t brightness) override { + for (uint8_t i = 0; i < 4; i++) { + writeReg(REG_LED0_PWM + i, brightness); + } + _brightness = brightness; + } + + uint8_t getBrightness() const { return _brightness; } + + virtual ~WioTrackerL2Backlight() = default; +}; + +class LGFX_WioTrackerL2 : public lgfx::LGFX_Device { + lgfx::Panel_NV3031B _panel_instance; + lgfx::Bus_SPI _bus_instance; + lgfx::Touch_GT911 _touch_instance; + WioTrackerL2Backlight _light_instance; + +public: + bool init_impl(bool use_reset, bool use_clear) override { + // bring up backlight controller while the I2C bus is still clean + _light_instance.init(_light_instance.getBrightness()); + + bool result = LGFX_Device::init_impl(use_reset, use_clear); + + // GT911 probe can leave the ESP32 I2C peripheral with a stuck BUSY flag; + // cycling Wire resets it so later LP5814/sensor traffic doesn't time out + Wire.end(); + Wire.begin(47, 48); + + return result; + } + + LGFX_WioTrackerL2(void) { + { + auto cfg = _bus_instance.config(); + cfg.spi_host = SPI3_HOST; + cfg.spi_mode = 3; + cfg.freq_write = L2_SPI_FREQUENCY; + cfg.freq_read = 16000000; + cfg.pin_sclk = 42; + // quad SPI data pins + cfg.pin_io0 = 41; + cfg.pin_io1 = 40; + cfg.pin_io2 = 39; + cfg.pin_io3 = 38; + _bus_instance.config(cfg); + _panel_instance.setBus(&_bus_instance); + } + + { + auto cfg = _panel_instance.config(); + cfg.pin_cs = 46; + cfg.pin_rst = -1; // reset is on the IO expander, done in board init + cfg.pin_busy = -1; + cfg.panel_width = 240; // native portrait orientation + cfg.panel_height = 320; + cfg.memory_width = 240; + cfg.memory_height = 320; + cfg.offset_x = 0; + cfg.offset_y = 0; + cfg.offset_rotation = 1; // panel mounted landscape (320x240) + cfg.invert = true; + cfg.rgb_order = true; + cfg.dlen_16bit = false; + cfg.bus_shared = false; + _panel_instance.config(cfg); + } + + { + auto cfg = _touch_instance.config(); + cfg.pin_cs = -1; + cfg.x_min = 0; + cfg.x_max = 239; + cfg.y_min = 0; + cfg.y_max = 319; + cfg.pin_int = -1; // INT is on the IO expander + cfg.offset_rotation = 2; + cfg.i2c_port = 0; + cfg.i2c_addr = 0x5D; + cfg.pin_sda = 47; + cfg.pin_scl = 48; + cfg.bus_shared = false; + cfg.freq = 400000; + _touch_instance.config(cfg); + _panel_instance.setTouch(&_touch_instance); + } + + _panel_instance.setLight(&_light_instance); + setPanel(&_panel_instance); + } +}; + +class WioTrackerL2Display : public LGFXDisplay { + LGFX_WioTrackerL2 disp; +public: + WioTrackerL2Display() : LGFXDisplay(320, 240, disp) {} + + // direct access to the LGFX device (LVGL flush/touch glue) + lgfx::LGFX_Device* lgfxDevice() { return &disp; } + + // reliable touch read in logical (UI_ZOOM-scaled) coords; returns false when + // not touched (LGFXDisplay::getTouch reads an uninitialized point on release) + bool readTouch(int& x, int& y) { + lgfx::touch_point_t tp; + if (disp.getTouch(&tp, 1) == 0) return false; + x = tp.x / UI_ZOOM; + y = tp.y / UI_ZOOM; + return true; + } + + bool getTouch(int* x, int* y) override { + return readTouch(*x, *y); + } + + // shadows LGFXDisplay::begin() (non-virtual, called on the concrete type in + // main.cpp) - identical except rotation: our panel config already carries + // offset_rotation=1 for the landscape mounting, so no extra rotation here + bool begin() { + // dark theme: deep navy ground, MeshCore blue accents (RGB565) + UIColor::window_bkg = 0x0885; // dark navy + UIColor::title_bkg = 0x1299; // meshcore blue + UIColor::title_txt = 0xFFFF; + UIColor::primary_txt = 0xE73C; // near-white + UIColor::secondary_txt = 0x8D59; // muted blue-gray + UIColor::warning_txt = 0xFD20; // orange + UIColor::popup_bkg = 0x1299; + UIColor::popup_txt = 0xFFFF; + UIColor::corp_blue = 0x5DBF; // sky blue (own messages, icons) + + turnOn(); + display->init(); + display->setRotation(0); + display->setColorDepth(8); + // The zoomed sprite blit stops a pixel short of the panel edge, so those + // pixels keep whatever the panel powered up with (they showed as coloured + // lines down the right side and along the bottom). Clear the panel once. + display->fillScreen(UIColor::window_bkg); + display->setBrightness(153); + display->setTextColor(TFT_WHITE); + + buffer.setColorDepth(8); + buffer.setPsram(true); + buffer.createSprite(width(), height()); + + return true; + } +}; diff --git a/variants/wio-tracker-l2/platformio.ini b/variants/wio-tracker-l2/platformio.ini new file mode 100644 index 00000000000..9434d081871 --- /dev/null +++ b/variants/wio-tracker-l2/platformio.ini @@ -0,0 +1,134 @@ +; Seeed Wio Tracker L2 Pro (alpha) - Wio-S3 module: ESP32-S3R8 + SX1262 +; Pin map derived from Seeed's Meshtastic support branch (meshtastic/firmware +; PR #10909 + device-ui `wio-l2` branch). +[Wio_Tracker_L2] +extends = esp32_base +board = seeed_wio_tracker_l2 +build_flags = ${esp32_base.build_flags} + ${sensor_base.build_flags} + -I variants/wio-tracker-l2 + -D SEEED_WIO_TRACKER_L2 + -D PIN_BOARD_SDA=47 + -D PIN_BOARD_SCL=48 + -D PIN_USER_BTN=0 + -D USE_SX1262 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=22 + -D P_LORA_NSS=21 + -D P_LORA_RESET=7 + -D P_LORA_BUSY=8 + -D P_LORA_DIO_1=9 + -D P_LORA_SCLK=4 + -D P_LORA_MISO=5 + -D P_LORA_MOSI=6 + -D SX126X_DIO2_AS_RF_SWITCH=true + -D SX126X_DIO3_TCXO_VOLTAGE=1.8 + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 + -D ENV_INCLUDE_GPS=1 + -D PIN_GPS_TX=18 ; L76K TX -> ESP32 RX + -D PIN_GPS_RX=17 ; L76K RX <- ESP32 TX + -D GPS_BAUD_RATE=9600 + -D ENV_SKIP_GPS_DETECT=1 ; GPS is always present, powered via IO expander + -D DISPLAY_CLASS=WioTrackerL2Display + -D UI_ZOOM=2 + ; disable ALL env sensor I2C probes: this bus carries six onboard chips + ; (TCA9535 0x21, AW35615 0x22, LP5814 0x2C, ADS1115 0x48, GT911 0x5D, + ; ES8311 0x18) and stray probe matches can hang sensors.begin() + -D ENV_INCLUDE_AHTX0=0 + -D ENV_INCLUDE_BME280=0 + -D ENV_INCLUDE_BMP280=0 + -D ENV_INCLUDE_SHTC3=0 + -D ENV_INCLUDE_SHT4X=0 + -D ENV_INCLUDE_INA3221=0 + -D ENV_INCLUDE_INA219=0 + -D ENV_INCLUDE_INA226=0 + -D ENV_INCLUDE_INA260=0 + -D ENV_INCLUDE_MLX90614=0 + -D ENV_INCLUDE_VL53L0X=0 + -D ENV_INCLUDE_BME680=0 + -D ENV_INCLUDE_BMP085=0 + -D ENV_INCLUDE_LPS22HB=0 +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/wio-tracker-l2> + + + + + + +lib_deps = + ${esp32_base.lib_deps} + ${sensor_base.lib_deps} + lovyan03/LovyanGFX @ 1.2.30 ; pinned: first release that links NV3031B under gnu++14 + https://github.com/pschatzmann/arduino-audio-driver/archive/v0.2.1.zip + +; -------- Companion: BLE phone app, standard display UI -------------------- +[env:Wio_Tracker_L2_companion_radio_ble] +extends = Wio_Tracker_L2 +build_flags = + ${Wio_Tracker_L2.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 + -D OFFLINE_QUEUE_SIZE=256 + -D HAS_TOUCH=1 + -D AUTO_OFF_MILLIS=60000 ; LCD, no burn-in worry; 1 min screen timeout +build_src_filter = ${Wio_Tracker_L2.build_src_filter} + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Wio_Tracker_L2.lib_deps} + densaugeo/base64 @ ~1.4.0 + +; -------- Companion: USB serial link, standard display UI ------------------ +[env:Wio_Tracker_L2_companion_radio_usb] +extends = Wio_Tracker_L2 +build_flags = + ${Wio_Tracker_L2.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE + -D HAS_TOUCH=1 + -D AUTO_OFF_MILLIS=60000 +build_src_filter = ${Wio_Tracker_L2.build_src_filter} + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Wio_Tracker_L2.lib_deps} + densaugeo/base64 @ ~1.4.0 + +; -------- Standalone repeater: extends mesh coverage, no phone needed ------- +[env:Wio_Tracker_L2_repeater] +extends = Wio_Tracker_L2 +build_flags = + ${Wio_Tracker_L2.build_flags} + -D ADVERT_NAME='"WioL2 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +build_src_filter = ${Wio_Tracker_L2.build_src_filter} + +<../examples/simple_repeater/*.cpp> +lib_deps = + ${Wio_Tracker_L2.lib_deps} + ${esp32_ota.lib_deps} + +; -------- Standalone room server: shared message board / BBS ---------------- +[env:Wio_Tracker_L2_room_server] +extends = Wio_Tracker_L2 +build_flags = + ${Wio_Tracker_L2.build_flags} + -D ADVERT_NAME='"WioL2 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +build_src_filter = ${Wio_Tracker_L2.build_src_filter} + +<../examples/simple_room_server> +lib_deps = + ${Wio_Tracker_L2.lib_deps} + ${esp32_ota.lib_deps} diff --git a/variants/wio-tracker-l2/target.cpp b/variants/wio-tracker-l2/target.cpp new file mode 100644 index 00000000000..a87da392495 --- /dev/null +++ b/variants/wio-tracker-l2/target.cpp @@ -0,0 +1,35 @@ +#include +#include "target.h" + +WioTrackerL2Board board; + +static SPIClass spi; +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); + +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); +GpsTapStream gps_tap(Serial1); // GSV sats-in-view / SNR watcher +MicroNMEALocationProvider gps(gps_tap, &rtc_clock); +EnvironmentSensorManager sensors(gps); + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; + MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true); +#endif + +bool radio_init() { + MESH_DEBUG_PRINTLN("radio_init: rtc + sx1262 init"); + fallback_clock.begin(); + rtc_clock.begin(Wire); // Wire already running on 47/48 from board.begin() + + bool ok = radio.std_init(&spi); + MESH_DEBUG_PRINTLN("radio_init: SX1262 %s", ok ? "OK" : "FAILED"); + return ok; +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} diff --git a/variants/wio-tracker-l2/target.h b/variants/wio-tracker-l2/target.h new file mode 100644 index 00000000000..11a66e4ec12 --- /dev/null +++ b/variants/wio-tracker-l2/target.h @@ -0,0 +1,30 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include "WioTrackerL2Board.h" +#include +#include +#include +#include +#include "GpsTap.h" +#ifdef DISPLAY_CLASS + #include "WioTrackerL2Display.h" + #include +#endif + +extern WioTrackerL2Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; +extern GpsTapStream gps_tap; + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); From e70400264aa7b1262520ff6ce84a36991266cc5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 18:57:04 +0200 Subject: [PATCH 28/44] deps: pin RadioLib 7.8.1; LR2021 reports the accepted RX gain level Replace the git commit pin with the 7.8.1 release. CustomLR2021 now updates its cached boosted-gain state only when setRxBoostedGainMode() succeeds, so getRxBoostedGainMode() reads back the level the radio accepted. From PowerSaving-v17 c0c03545. --- platformio.ini | 3 +-- src/helpers/radiolib/CustomLR2021.h | 5 +++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/platformio.ini b/platformio.ini index 9c2d2ea668e..4699dd98f30 100644 --- a/platformio.ini +++ b/platformio.ini @@ -19,8 +19,7 @@ monitor_speed = 115200 lib_deps = SPI Wire - ;jgromes/RadioLib @ ^7.7.1 - https://github.com/jgromes/RadioLib.git#6d8934836678d8894e3d556550475b37dce3e2b6 + jgromes/RadioLib @ 7.8.1 rweather/Crypto @ ^0.4.0 adafruit/RTClib @ ^2.1.3 melopero/Melopero RV3028 @ ^1.1.0 diff --git a/src/helpers/radiolib/CustomLR2021.h b/src/helpers/radiolib/CustomLR2021.h index 0255b93cf29..1257f2ba751 100644 --- a/src/helpers/radiolib/CustomLR2021.h +++ b/src/helpers/radiolib/CustomLR2021.h @@ -68,6 +68,11 @@ class CustomLR2021 : public LR2021 { float getFreqMHz() const { return freqMHz; } + int16_t setRxBoostedGainMode(uint8_t level) { + const int16_t status = LR2021::setRxBoostedGainMode(level); + if (status == RADIOLIB_ERR_NONE) _rx_boosted = level != 0; + return status; + } bool getRxBoostedGainMode() const { return _rx_boosted; } int16_t startReceive() override { From 366400ad4d2126f2167fe808f7f968cc281cf891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 20:10:19 +0200 Subject: [PATCH 29/44] nrf52: keep the software clock across resets in retained RAM VolatileRTCClock stores the current time and a magic number in an 8-byte NOLOAD .persistent section and restores it after a reset. A bad magic or a time before 2026-03-01 falls back to that date. prev_millis is uint32_t so the millis() wrap does not jump the clock. The v6, v7 and extrafs linker scripts gain the .persistent section, and ten nRF52 board bases select the repo's script explicitly so the section is present on every build. Retention covers software resets only, not power loss. From PowerSaving-v17 2384af19 and 772dc847. --- boards/nrf52840_s140_v6.ld | 87 +++++++++++-------- boards/nrf52840_s140_v6_extrafs.ld | 19 +++- boards/nrf52840_s140_v7.ld | 19 +++- boards/nrf52840_s140_v7_extrafs.ld | 19 +++- src/helpers/ArduinoHelpers.cpp | 7 ++ src/helpers/ArduinoHelpers.h | 48 +++++++++- .../test_retained_clock.cpp | 26 ++++++ variants/gat562_30s_mesh_kit/platformio.ini | 1 + variants/gat562_mesh_evb_pro/platformio.ini | 1 + .../gat562_mesh_tracker_pro/platformio.ini | 1 + variants/gat562_mesh_watch13/platformio.ini | 1 + variants/keepteen_lt1/platformio.ini | 1 + variants/muziworks_r1_neo/platformio.ini | 1 + variants/promicro/platformio.ini | 1 + variants/rak3401/platformio.ini | 1 + variants/rak4631/platformio.ini | 1 + variants/rak_wismesh_tag/platformio.ini | 1 + 17 files changed, 181 insertions(+), 54 deletions(-) create mode 100644 src/helpers/ArduinoHelpers.cpp create mode 100644 test/test_retained_clock/test_retained_clock.cpp diff --git a/boards/nrf52840_s140_v6.ld b/boards/nrf52840_s140_v6.ld index 6dad975b0d8..43e182571b9 100644 --- a/boards/nrf52840_s140_v6.ld +++ b/boards/nrf52840_s140_v6.ld @@ -1,38 +1,49 @@ -/* Linker script to configure memory regions. */ - -SEARCH_DIR(.) -GROUP(-lgcc -lc -lnosys) - -MEMORY -{ - FLASH (rx) : ORIGIN = 0x26000, LENGTH = 0xED000 - 0x26000 - - /* SRAM required by Softdevice depend on - * - Attribute Table Size (Number of Services and Characteristics) - * - Vendor UUID count - * - Max ATT MTU - * - Concurrent connection peripheral + central + secure links - * - Event Len, HVN queue, Write CMD queue - */ - RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 0x20040000 - 0x20006000 -} - -SECTIONS -{ - . = ALIGN(4); - .svc_data : - { - PROVIDE(__start_svc_data = .); - KEEP(*(.svc_data)) - PROVIDE(__stop_svc_data = .); - } > RAM - - .fs_data : - { - PROVIDE(__start_fs_data = .); - KEEP(*(.fs_data)) - PROVIDE(__stop_fs_data = .); - } > RAM -} INSERT AFTER .data; - -INCLUDE "nrf52_common.ld" +/* Linker script to configure memory regions. */ + +SEARCH_DIR(.) +GROUP(-lgcc -lc -lnosys) + +MEMORY +{ + FLASH (rx) : ORIGIN = 0x26000, LENGTH = 0xED000 - 0x26000 + + /* To keep data in RAM across resets */ + PERSISTENT_RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 8 + + /* SRAM required by Softdevice depend on + * - Attribute Table Size (Number of Services and Characteristics) + * - Vendor UUID count + * - Max ATT MTU + * - Concurrent connection peripheral + central + secure links + * - Event Len, HVN queue, Write CMD queue + */ + RAM (rwx) : ORIGIN = 0x20006000 + 8, LENGTH = 0x20040000 - 0x20006000 - 8 +} + +SECTIONS +{ + . = ALIGN(4); + .persistent (NOLOAD) : + { + KEEP(*(.persistent_magic)) + KEEP(*(.persistent_data)) + . = ALIGN(4); + } > PERSISTENT_RAM + + . = ALIGN(4); + .svc_data : + { + PROVIDE(__start_svc_data = .); + KEEP(*(.svc_data)) + PROVIDE(__stop_svc_data = .); + } > RAM + + .fs_data : + { + PROVIDE(__start_fs_data = .); + KEEP(*(.fs_data)) + PROVIDE(__stop_fs_data = .); + } > RAM +} + +INCLUDE "nrf52_common.ld" diff --git a/boards/nrf52840_s140_v6_extrafs.ld b/boards/nrf52840_s140_v6_extrafs.ld index 352610679e1..14eef6114a1 100644 --- a/boards/nrf52840_s140_v6_extrafs.ld +++ b/boards/nrf52840_s140_v6_extrafs.ld @@ -7,18 +7,29 @@ MEMORY { FLASH (rx) : ORIGIN = 0x26000, LENGTH = 0xD4000 - 0x26000 + /* To keep data in RAM across resets */ + PERSISTENT_RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 8 + /* SRAM required by Softdevice depend on * - Attribute Table Size (Number of Services and Characteristics) * - Vendor UUID count * - Max ATT MTU * - Concurrent connection peripheral + central + secure links * - Event Len, HVN queue, Write CMD queue - */ - RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 0x20040000 - 0x20006000 + */ + RAM (rwx) : ORIGIN = 0x20006000 + 8, LENGTH = 0x20040000 - 0x20006000 - 8 } SECTIONS { + . = ALIGN(4); + .persistent (NOLOAD) : + { + KEEP(*(.persistent_magic)) + KEEP(*(.persistent_data)) + . = ALIGN(4); + } > PERSISTENT_RAM + . = ALIGN(4); .svc_data : { @@ -26,13 +37,13 @@ SECTIONS KEEP(*(.svc_data)) PROVIDE(__stop_svc_data = .); } > RAM - + .fs_data : { PROVIDE(__start_fs_data = .); KEEP(*(.fs_data)) PROVIDE(__stop_fs_data = .); } > RAM -} INSERT AFTER .data; +} INCLUDE "nrf52_common.ld" diff --git a/boards/nrf52840_s140_v7.ld b/boards/nrf52840_s140_v7.ld index 6aaeb4034fe..d7626251882 100644 --- a/boards/nrf52840_s140_v7.ld +++ b/boards/nrf52840_s140_v7.ld @@ -7,18 +7,29 @@ MEMORY { FLASH (rx) : ORIGIN = 0x27000, LENGTH = 0xED000 - 0x27000 + /* To keep data in RAM across resets */ + PERSISTENT_RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 8 + /* SRAM required by Softdevice depend on * - Attribute Table Size (Number of Services and Characteristics) * - Vendor UUID count * - Max ATT MTU * - Concurrent connection peripheral + central + secure links * - Event Len, HVN queue, Write CMD queue - */ - RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 0x20040000 - 0x20006000 + */ + RAM (rwx) : ORIGIN = 0x20006000 + 8, LENGTH = 0x20040000 - 0x20006000 - 8 } SECTIONS { + . = ALIGN(4); + .persistent (NOLOAD) : + { + KEEP(*(.persistent_magic)) + KEEP(*(.persistent_data)) + . = ALIGN(4); + } > PERSISTENT_RAM + . = ALIGN(4); .svc_data : { @@ -26,13 +37,13 @@ SECTIONS KEEP(*(.svc_data)) PROVIDE(__stop_svc_data = .); } > RAM - + .fs_data : { PROVIDE(__start_fs_data = .); KEEP(*(.fs_data)) PROVIDE(__stop_fs_data = .); } > RAM -} INSERT AFTER .data; +} INCLUDE "nrf52_common.ld" diff --git a/boards/nrf52840_s140_v7_extrafs.ld b/boards/nrf52840_s140_v7_extrafs.ld index 5956183aa39..3fa3baf87da 100644 --- a/boards/nrf52840_s140_v7_extrafs.ld +++ b/boards/nrf52840_s140_v7_extrafs.ld @@ -7,18 +7,29 @@ MEMORY { FLASH (rx) : ORIGIN = 0x27000, LENGTH = 0xD4000 - 0x27000 + /* To keep data in RAM across resets */ + PERSISTENT_RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 8 + /* SRAM required by Softdevice depend on * - Attribute Table Size (Number of Services and Characteristics) * - Vendor UUID count * - Max ATT MTU * - Concurrent connection peripheral + central + secure links * - Event Len, HVN queue, Write CMD queue - */ - RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 0x20040000 - 0x20006000 + */ + RAM (rwx) : ORIGIN = 0x20006000 + 8, LENGTH = 0x20040000 - 0x20006000 - 8 } SECTIONS { + . = ALIGN(4); + .persistent (NOLOAD) : + { + KEEP(*(.persistent_magic)) + KEEP(*(.persistent_data)) + . = ALIGN(4); + } > PERSISTENT_RAM + . = ALIGN(4); .svc_data : { @@ -26,13 +37,13 @@ SECTIONS KEEP(*(.svc_data)) PROVIDE(__stop_svc_data = .); } > RAM - + .fs_data : { PROVIDE(__start_fs_data = .); KEEP(*(.fs_data)) PROVIDE(__stop_fs_data = .); } > RAM -} INSERT AFTER .data; +} INCLUDE "nrf52_common.ld" diff --git a/src/helpers/ArduinoHelpers.cpp b/src/helpers/ArduinoHelpers.cpp new file mode 100644 index 00000000000..dcc6039f1ad --- /dev/null +++ b/src/helpers/ArduinoHelpers.cpp @@ -0,0 +1,7 @@ +#include +#ifdef NRF52_PLATFORM +extern "C" { +__attribute__((section(".persistent_magic"))) volatile uint32_t persistent_magic; +__attribute__((section(".persistent_data"))) volatile uint32_t persistent_time; +} +#endif diff --git a/src/helpers/ArduinoHelpers.h b/src/helpers/ArduinoHelpers.h index 97596daa31f..ba03b7ce4e8 100644 --- a/src/helpers/ArduinoHelpers.h +++ b/src/helpers/ArduinoHelpers.h @@ -3,19 +3,59 @@ #include #include +#ifdef NRF52_PLATFORM +#define CLOCK_MAGIC_NUM 0xAA55CC33 +#define RTC_TIME_MIN 1772323200 // 1 Mar 2026 + +extern "C" { +extern volatile uint32_t persistent_magic; +extern volatile uint32_t persistent_time; +} +#endif + class VolatileRTCClock : public mesh::RTCClock { uint32_t base_time; uint64_t accumulator; - unsigned long prev_millis; + uint32_t prev_millis; + public: - VolatileRTCClock() { base_time = 1715770351; accumulator = 0; prev_millis = millis(); } // 15 May 2024, 8:50pm + VolatileRTCClock() { +#ifdef NRF52_PLATFORM + if (persistent_magic == CLOCK_MAGIC_NUM && persistent_time >= RTC_TIME_MIN) { + base_time = persistent_time; + } else { + base_time = RTC_TIME_MIN; + } +#else + base_time = 1715770351; +#endif + + accumulator = 0; + prev_millis = millis(); + } + uint32_t getCurrentTime() override { return base_time + accumulator/1000; } - void setCurrentTime(uint32_t time) override { base_time = time; accumulator = 0; prev_millis = millis(); } + + void setCurrentTime(uint32_t time) override { + base_time = time; + accumulator = 0; + prev_millis = millis(); + +#ifdef NRF52_PLATFORM + persistent_time = time; + persistent_magic = CLOCK_MAGIC_NUM; +#endif + } void tick() override { - unsigned long now = millis(); + uint32_t now = millis(); accumulator += (now - prev_millis); prev_millis = now; + +#ifdef NRF52_PLATFORM + persistent_time = getCurrentTime(); + persistent_magic = CLOCK_MAGIC_NUM; +#endif } }; diff --git a/test/test_retained_clock/test_retained_clock.cpp b/test/test_retained_clock/test_retained_clock.cpp new file mode 100644 index 00000000000..ce92c03d3e2 --- /dev/null +++ b/test/test_retained_clock/test_retained_clock.cpp @@ -0,0 +1,26 @@ +#include +#define NRF52_PLATFORM +inline void randomSeed(long) {} +inline long random(long low, long) {return low;} +#include +extern "C" { volatile uint32_t persistent_magic; volatile uint32_t persistent_time; } +TEST(RetainedClock, ValidTimeSurvivesSoftwareRestart) { + persistent_magic=0;g_mock_millis=0; + VolatileRTCClock first;first.setCurrentTime(RTC_TIME_MIN+123); + g_mock_millis=1500;first.tick();EXPECT_EQ(RTC_TIME_MIN+124,first.getCurrentTime()); + g_mock_millis=0;VolatileRTCClock restarted; + EXPECT_EQ(RTC_TIME_MIN+124,restarted.getCurrentTime()); +} +TEST(RetainedClock, InvalidMagicOrTimeUsesSeed) { + persistent_magic=0;persistent_time=RTC_TIME_MIN+123; + VolatileRTCClock bad_magic;EXPECT_EQ(RTC_TIME_MIN,bad_magic.getCurrentTime()); + persistent_magic=CLOCK_MAGIC_NUM;persistent_time=RTC_TIME_MIN-1; + VolatileRTCClock bad_time;EXPECT_EQ(RTC_TIME_MIN,bad_time.getCurrentTime()); +} +TEST(RetainedClock, MillisRolloverDoesNotJumpTime) { + persistent_magic=0;g_mock_millis=0xFFFFFF00U; + VolatileRTCClock clock; + g_mock_millis=744;clock.tick();EXPECT_EQ(RTC_TIME_MIN+1,clock.getCurrentTime()); + EXPECT_EQ(clock.getCurrentTime(),persistent_time); +} +int main(int argc,char**argv) {testing::InitGoogleTest(&argc,argv);return RUN_ALL_TESTS();} diff --git a/variants/gat562_30s_mesh_kit/platformio.ini b/variants/gat562_30s_mesh_kit/platformio.ini index 326e3f705cb..410edf9ed29 100644 --- a/variants/gat562_30s_mesh_kit/platformio.ini +++ b/variants/gat562_30s_mesh_kit/platformio.ini @@ -1,5 +1,6 @@ [GAT562_30S_Mesh_Kit] extends = nrf52_base +board_build.ldscript = boards/nrf52840_s140_v6.ld board = rak4631 board_check = true build_flags = ${nrf52_base.build_flags} diff --git a/variants/gat562_mesh_evb_pro/platformio.ini b/variants/gat562_mesh_evb_pro/platformio.ini index 280deb1f0f6..a5170ba5d41 100644 --- a/variants/gat562_mesh_evb_pro/platformio.ini +++ b/variants/gat562_mesh_evb_pro/platformio.ini @@ -1,5 +1,6 @@ [GAT562_Mesh_EVB_Pro] extends = nrf52_base +board_build.ldscript = boards/nrf52840_s140_v6.ld board = rak4631 board_check = true build_flags = ${nrf52_base.build_flags} diff --git a/variants/gat562_mesh_tracker_pro/platformio.ini b/variants/gat562_mesh_tracker_pro/platformio.ini index 1141cee622f..60c39522838 100644 --- a/variants/gat562_mesh_tracker_pro/platformio.ini +++ b/variants/gat562_mesh_tracker_pro/platformio.ini @@ -1,5 +1,6 @@ [GAT562_Mesh_Tracker_Pro] extends = nrf52_base +board_build.ldscript = boards/nrf52840_s140_v6.ld board = rak4631 board_check = true build_flags = ${nrf52_base.build_flags} diff --git a/variants/gat562_mesh_watch13/platformio.ini b/variants/gat562_mesh_watch13/platformio.ini index d8ca3052786..2f4fb526bf5 100644 --- a/variants/gat562_mesh_watch13/platformio.ini +++ b/variants/gat562_mesh_watch13/platformio.ini @@ -1,5 +1,6 @@ [GAT562_Mesh_Watch13] extends = nrf52_base +board_build.ldscript = boards/nrf52840_s140_v6.ld board = rak4631 board_check = true build_flags = ${nrf52_base.build_flags} diff --git a/variants/keepteen_lt1/platformio.ini b/variants/keepteen_lt1/platformio.ini index 11dc214b09f..e35a4ffa119 100644 --- a/variants/keepteen_lt1/platformio.ini +++ b/variants/keepteen_lt1/platformio.ini @@ -1,5 +1,6 @@ [KeepteenLT1] extends = nrf52_base +board_build.ldscript = boards/nrf52840_s140_v6.ld board = keepteen_lt1 build_flags = ${nrf52_base.build_flags} -I variants/keepteen_lt1 diff --git a/variants/muziworks_r1_neo/platformio.ini b/variants/muziworks_r1_neo/platformio.ini index fb51c04d94c..02f4c50f559 100644 --- a/variants/muziworks_r1_neo/platformio.ini +++ b/variants/muziworks_r1_neo/platformio.ini @@ -1,5 +1,6 @@ [R1Neo] extends = nrf52_base +board_build.ldscript = boards/nrf52840_s140_v6.ld board = rak4631 board_check = true build_flags = ${nrf52_base.build_flags} diff --git a/variants/promicro/platformio.ini b/variants/promicro/platformio.ini index da27c2fab20..f5b73c70c13 100644 --- a/variants/promicro/platformio.ini +++ b/variants/promicro/platformio.ini @@ -1,5 +1,6 @@ [Promicro] extends = nrf52_base +board_build.ldscript = boards/nrf52840_s140_v6.ld board = promicro_nrf52840 build_flags = ${nrf52_base.build_flags} -I variants/promicro diff --git a/variants/rak3401/platformio.ini b/variants/rak3401/platformio.ini index 2ca47276fc3..cfaa8680601 100644 --- a/variants/rak3401/platformio.ini +++ b/variants/rak3401/platformio.ini @@ -1,5 +1,6 @@ [rak3401] extends = nrf52_base +board_build.ldscript = boards/nrf52840_s140_v6.ld board = rak3401 board_check = true extra_scripts = ${nrf52_base.extra_scripts} diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 4c32a47865f..0bf6a8834e3 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -1,5 +1,6 @@ [rak4631] extends = nrf52_base +board_build.ldscript = boards/nrf52840_s140_v6.ld board = rak4631 board_check = true extra_scripts = ${nrf52_base.extra_scripts} diff --git a/variants/rak_wismesh_tag/platformio.ini b/variants/rak_wismesh_tag/platformio.ini index d6140187170..a2a61f98950 100644 --- a/variants/rak_wismesh_tag/platformio.ini +++ b/variants/rak_wismesh_tag/platformio.ini @@ -1,5 +1,6 @@ [rak_wismesh_tag] extends = nrf52_base +board_build.ldscript = boards/nrf52840_s140_v6.ld board = rak4631 board_check = true build_flags = ${nrf52_base.build_flags} From 0488672c2e7f02acaa8fcea03bbd0f99dba0fdab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Thu, 15 Oct 2026 21:51:18 +0200 Subject: [PATCH 30/44] boards: share peripheral shutdown between power off and timed sleep MainBoard gains shutdownPeripherals() and enterDeepSleep(secs). Board hooks that used to override powerOff() now override shutdownPeripherals(), so a timed sleep turns off the same GPS, display, LEDs, FEM/PA and rails as a full power off. - ESP32: enterDeepSleep() runs shutdownPeripherals() and arms the timer wake-up. - nRF52: SYSTEMOFF has no timer wake, so a timed sleep keeps the RTOS running in one-second steps, advances the retained clock, then reboots. - Heltec V4 R8: its own deep-sleep path left the RX path on; it now uses the shared one, which turns off the radio and FEM. - Wio Tracker L1 1W: the PA is now also turned off on timed sleep. From PowerSaving-v17 d9c42964. --- src/MeshCore.h | 2 ++ src/helpers/ESP32Board.cpp | 7 ++++- src/helpers/ESP32Board.h | 3 +- src/helpers/NRF52Board.cpp | 20 +++++++++++++ src/helpers/NRF52Board.h | 3 +- .../GAT56230SMeshKitBoard.h | 4 +-- .../gat562_mesh_evb_pro/GAT562EVBProBoard.h | 4 +-- .../GAT562MeshTrackerProBoard.h | 4 +-- .../GAT56MeshWatch13Board.h | 4 +-- variants/heltec_rc32/HeltecRC32Board.cpp | 3 -- variants/heltec_rc32/HeltecRC32Board.h | 1 - variants/heltec_t096/T096Board.cpp | 4 +-- variants/heltec_t096/T096Board.h | 2 +- variants/heltec_t1/T1Board.cpp | 4 +-- variants/heltec_t1/T1Board.h | 2 +- variants/heltec_t114/T114Board.h | 4 +-- .../heltec_tower_v2/HeltecTowerV2Board.cpp | 4 +-- variants/heltec_tower_v2/HeltecTowerV2Board.h | 2 +- .../HeltecTrackerV2Board.cpp | 4 +-- .../heltec_tracker_v2/HeltecTrackerV2Board.h | 2 +- variants/heltec_v4/HeltecV4Board.cpp | 4 +-- variants/heltec_v4/HeltecV4Board.h | 2 +- variants/heltec_v4_r8/HeltecV4R8Board.cpp | 30 ++++--------------- variants/heltec_v4_r8/HeltecV4R8Board.h | 3 +- variants/lilygo_tbeam_1w/TBeam1WBoard.cpp | 4 +-- variants/lilygo_tbeam_1w/TBeam1WBoard.h | 2 +- variants/meshtiny/MeshtinyBoard.h | 4 +-- variants/meshtracker_x1/MeshTrackerX1Board.h | 4 +-- .../MinewsemiME25LS01Board.h | 4 +-- variants/muziworks_r1_neo/R1NeoBoard.h | 1 + variants/nano_g2_ultra/nano-g2.h | 4 +-- variants/rak_wismesh_tag/RAKWismeshTagBoard.h | 4 +-- variants/sensecap_solar/SenseCapSolarBoard.h | 7 ++++- variants/station_g3_esp32/StationG3Board.cpp | 4 +-- variants/station_g3_esp32/StationG3Board.h | 2 +- variants/t1000-e/T1000eBoard.h | 4 +-- variants/thinknode_m1/ThinkNodeM1Board.h | 4 +-- variants/thinknode_m3/ThinkNodeM3Board.h | 4 +-- variants/thinknode_m6/ThinkNodeM6Board.h | 4 +-- .../wio-tracker-l1-1w/WioTrackerL1Board.h | 4 +-- variants/wio-tracker-l1/WioTrackerL1Board.h | 3 -- variants/xiao_nrf52/XiaoNrf52Board.h | 4 +-- 42 files changed, 99 insertions(+), 90 deletions(-) diff --git a/src/MeshCore.h b/src/MeshCore.h index 45d8071bec9..d454bf2c178 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -55,6 +55,8 @@ class MainBoard { virtual void onBeforeTransmit() { } virtual void onAfterTransmit() { } virtual void reboot() = 0; + virtual void shutdownPeripherals() { } + virtual void enterDeepSleep(uint32_t secs) { if (secs == 0) powerOff(); } virtual void powerOff() { /* no op */ } // Called by example setup() functions to signal that boot is complete. // Boards may override to stop a boot-indicator LED sequence or similar. diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index c48916e058b..e24a59681b0 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -50,7 +50,7 @@ void ESP32Board::powerOff() { enterDeepSleep(0); // Do not wakeup } -void ESP32Board::enterDeepSleep(uint32_t secs) { +void ESP32Board::shutdownPeripherals() { // Power off the display if any #ifdef DISPLAY_CLASS display.turnOff(); @@ -78,6 +78,11 @@ void ESP32Board::enterDeepSleep(uint32_t secs) { Serial.flush(); delay(100); +} + +void ESP32Board::enterDeepSleep(uint32_t secs) { + shutdownPeripherals(); + // Clear stale wakeup sources to avoid ghost wakeup // This is required when Power Management and automatic lightsleep are enabled esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL); diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index ba986bc12ee..90b3e111d19 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -69,7 +69,8 @@ class ESP32Board : public mesh::MainBoard { } virtual void powerOff() override; - void enterDeepSleep(uint32_t secs); + void shutdownPeripherals() override; + void enterDeepSleep(uint32_t secs) override; uint32_t getIRQGpio() override { #ifdef P_LORA_DIO_1 diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index f514016f84a..4401961c0cf 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -1,5 +1,6 @@ #if defined(NRF52_PLATFORM) #include "NRF52Board.h" +#include "ArduinoHelpers.h" #include #include @@ -390,6 +391,25 @@ void NRF52Board::powerOff() { } } +void NRF52Board::enterDeepSleep(uint32_t secs) { + if (secs == 0) { powerOff(); return; } + shutdownPeripherals(); +#if (__FPU_USED == 1) + __set_FPSCR(__get_FPSCR() & ~(0x0000009F)); + (void)__get_FPSCR(); + NVIC_ClearPendingIRQ(FPU_IRQn); +#endif + // nRF SYSTEMOFF has no timer wake: keep the RTOS/RTC running, then reboot. + // One-second chunks avoid overflow and checkpoint retained time during sleep. + while (secs--) { + vTaskDelay(pdMS_TO_TICKS(1000)); + if (persistent_magic == CLOCK_MAGIC_NUM && persistent_time >= RTC_TIME_MIN) { + ++persistent_time; + } + } + reboot(); +} + bool NRF52Board::getBootloaderVersion(char* out, size_t max_len) { static const char BOOTLOADER_MARKER[] = "UF2 Bootloader "; const uint8_t* flash = (const uint8_t*)0x000FB000; // earliest known info.txt location is 0xFB90B, latest is 0xFCC4B diff --git a/src/helpers/NRF52Board.h b/src/helpers/NRF52Board.h index 43944b5f7cf..9ca683d76c9 100644 --- a/src/helpers/NRF52Board.h +++ b/src/helpers/NRF52Board.h @@ -52,7 +52,8 @@ class NRF52Board : public mesh::MainBoard { virtual uint8_t getStartupReason() const override { return startup_reason; } virtual float getMCUTemperature() override; virtual void reboot() override { NVIC_SystemReset(); } - virtual void shutdownPeripherals(); + void shutdownPeripherals() override; + void enterDeepSleep(uint32_t secs) override; virtual void powerOff() override; virtual bool getBootloaderVersion(char* version, size_t max_len) override; virtual bool startOTAUpdate(const char *id, char reply[]) override; diff --git a/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h b/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h index 0b0f701c264..36e588b9985 100644 --- a/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h +++ b/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h @@ -43,11 +43,11 @@ class GAT56230SMeshKitBoard : public NRF52BoardDCDC { } #endif - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); uint32_t button_pin = PIN_BUTTON1; nrf_gpio_cfg_input(button_pin, NRF_GPIO_PIN_PULLUP); nrf_gpio_cfg_sense_set(button_pin, NRF_GPIO_PIN_SENSE_LOW); - NRF52Board::powerOff(); } }; diff --git a/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h b/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h index 66c770994d4..67e9df92b84 100644 --- a/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h +++ b/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h @@ -43,11 +43,11 @@ class GAT562EVBProBoard : public NRF52BoardDCDC { } #endif - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); uint32_t button_pin = PIN_BUTTON1; nrf_gpio_cfg_input(button_pin, NRF_GPIO_PIN_PULLUP); nrf_gpio_cfg_sense_set(button_pin, NRF_GPIO_PIN_SENSE_LOW); - NRF52Board::powerOff(); } }; diff --git a/variants/gat562_mesh_tracker_pro/GAT562MeshTrackerProBoard.h b/variants/gat562_mesh_tracker_pro/GAT562MeshTrackerProBoard.h index 39b55911a1b..7bab9d57706 100644 --- a/variants/gat562_mesh_tracker_pro/GAT562MeshTrackerProBoard.h +++ b/variants/gat562_mesh_tracker_pro/GAT562MeshTrackerProBoard.h @@ -43,11 +43,11 @@ class GAT562MeshTrackerProBoard : public NRF52BoardDCDC { } #endif - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); uint32_t button_pin = PIN_BUTTON1; nrf_gpio_cfg_input(button_pin, NRF_GPIO_PIN_PULLUP); nrf_gpio_cfg_sense_set(button_pin, NRF_GPIO_PIN_SENSE_LOW); - NRF52Board::powerOff(); } }; diff --git a/variants/gat562_mesh_watch13/GAT56MeshWatch13Board.h b/variants/gat562_mesh_watch13/GAT56MeshWatch13Board.h index 805ca67f242..045ffeeae1c 100644 --- a/variants/gat562_mesh_watch13/GAT56MeshWatch13Board.h +++ b/variants/gat562_mesh_watch13/GAT56MeshWatch13Board.h @@ -34,11 +34,11 @@ class GAT56MeshWatch13Board : public NRF52BoardDCDC { } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); uint32_t button_pin = PIN_BUTTON1; nrf_gpio_cfg_input(button_pin, NRF_GPIO_PIN_PULLUP); nrf_gpio_cfg_sense_set(button_pin, NRF_GPIO_PIN_SENSE_LOW); - NRF52Board::powerOff(); } }; diff --git a/variants/heltec_rc32/HeltecRC32Board.cpp b/variants/heltec_rc32/HeltecRC32Board.cpp index 59920d238f0..42bf2339dfc 100644 --- a/variants/heltec_rc32/HeltecRC32Board.cpp +++ b/variants/heltec_rc32/HeltecRC32Board.cpp @@ -31,9 +31,6 @@ void HeltecRC32Board::begin() { } } -void HeltecRC32Board::powerOff() { - enterDeepSleep(0); -} void HeltecRC32Board::onBeforeTransmit() { digitalWrite(P_LORA_TX_LED, HIGH); diff --git a/variants/heltec_rc32/HeltecRC32Board.h b/variants/heltec_rc32/HeltecRC32Board.h index 5b2093c8f6e..e53111e71c6 100644 --- a/variants/heltec_rc32/HeltecRC32Board.h +++ b/variants/heltec_rc32/HeltecRC32Board.h @@ -21,7 +21,6 @@ class HeltecRC32Board : public ESP32Board { void begin(); void onBeforeTransmit() override; void onAfterTransmit() override; - void powerOff() override; uint16_t getBattMilliVolts() override; bool setAdcMultiplier(float multiplier) override; float getAdcMultiplier() const override { return adc_mult; } diff --git a/variants/heltec_t096/T096Board.cpp b/variants/heltec_t096/T096Board.cpp index ea26d2cc15a..a4d65a0e155 100644 --- a/variants/heltec_t096/T096Board.cpp +++ b/variants/heltec_t096/T096Board.cpp @@ -111,10 +111,10 @@ void T096Board::variant_shutdown() { nrf_gpio_cfg_default(PIN_GPS_TX); } -void T096Board::powerOff() { +void T096Board::shutdownPeripherals() { + NRF52Board::shutdownPeripherals(); loRaFEMControl.setSleepModeEnable(); nrf_gpio_cfg_default(PIN_GPS_EN); // 363uA down to 39uA - NRF52Board::powerOff(); } const char* T096Board::getManufacturerName() const { diff --git a/variants/heltec_t096/T096Board.h b/variants/heltec_t096/T096Board.h index e926c8a6a31..70e2693e56b 100644 --- a/variants/heltec_t096/T096Board.h +++ b/variants/heltec_t096/T096Board.h @@ -32,5 +32,5 @@ class T096Board : public NRF52BoardDCDC { void onAfterTransmit(void) override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override ; - void powerOff() override; + void shutdownPeripherals() override; }; diff --git a/variants/heltec_t1/T1Board.cpp b/variants/heltec_t1/T1Board.cpp index 1ef5b766b34..275c1466de0 100644 --- a/variants/heltec_t1/T1Board.cpp +++ b/variants/heltec_t1/T1Board.cpp @@ -97,9 +97,9 @@ void T1Board::variant_shutdown() { digitalWrite(PIN_BAT_CTL, !ADC_CTRL_ENABLED); } -void T1Board::powerOff() { +void T1Board::shutdownPeripherals() { + NRF52Board::shutdownPeripherals(); variant_shutdown(); - NRF52Board::powerOff(); } const char* T1Board::getManufacturerName() const { diff --git a/variants/heltec_t1/T1Board.h b/variants/heltec_t1/T1Board.h index 9b46fd11a89..3e9afd1b914 100644 --- a/variants/heltec_t1/T1Board.h +++ b/variants/heltec_t1/T1Board.h @@ -22,5 +22,5 @@ class T1Board : public NRF52BoardDCDC { void onAfterTransmit() override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override; - void powerOff() override; + void shutdownPeripherals() override; }; diff --git a/variants/heltec_t114/T114Board.h b/variants/heltec_t114/T114Board.h index bd76d3de44c..e5b1bc8fdb2 100644 --- a/variants/heltec_t114/T114Board.h +++ b/variants/heltec_t114/T114Board.h @@ -46,11 +46,11 @@ class T114Board : public NRF52BoardDCDC { return "Heltec T114"; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); #ifdef LED_PIN digitalWrite(LED_PIN, HIGH); #endif - NRF52Board::powerOff(); } }; diff --git a/variants/heltec_tower_v2/HeltecTowerV2Board.cpp b/variants/heltec_tower_v2/HeltecTowerV2Board.cpp index 04213bda8c5..9bf290fc59b 100644 --- a/variants/heltec_tower_v2/HeltecTowerV2Board.cpp +++ b/variants/heltec_tower_v2/HeltecTowerV2Board.cpp @@ -88,7 +88,8 @@ const char* HeltecTowerV2Board::getManufacturerName() const { return "Heltec Tower V2"; } -void HeltecTowerV2Board::powerOff() { +void HeltecTowerV2Board::shutdownPeripherals() { + NRF52Board::shutdownPeripherals(); pinMode(PIN_GPS_EN, OUTPUT); digitalWrite(PIN_GPS_EN, !PIN_GPS_EN_ACTIVE); pinMode(PIN_GPS_STANDBY, OUTPUT); @@ -99,5 +100,4 @@ void HeltecTowerV2Board::powerOff() { pinMode(PIN_BAT_CTL, OUTPUT); digitalWrite(PIN_BAT_CTL, LOW); variant_shutdown(); - sd_power_system_off(); } diff --git a/variants/heltec_tower_v2/HeltecTowerV2Board.h b/variants/heltec_tower_v2/HeltecTowerV2Board.h index 6912acdec7a..b5e7f12718a 100644 --- a/variants/heltec_tower_v2/HeltecTowerV2Board.h +++ b/variants/heltec_tower_v2/HeltecTowerV2Board.h @@ -20,5 +20,5 @@ class HeltecTowerV2Board : public NRF52BoardDCDC { void onAfterTransmit() override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override; - void powerOff() override; + void shutdownPeripherals() override; }; diff --git a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp index 753824a05ae..d5bffcb9313 100644 --- a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp +++ b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp @@ -35,12 +35,12 @@ void HeltecTrackerV2Board::begin() { loRaFEMControl.setRxModeEnable(); } - void HeltecTrackerV2Board::powerOff() { + void HeltecTrackerV2Board::shutdownPeripherals() { + ESP32Board::shutdownPeripherals(); // Turn off PA digitalWrite(P_LORA_PA_POWER, LOW); rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER); - ESP32Board::powerOff(); } uint16_t HeltecTrackerV2Board::getBattMilliVolts() { diff --git a/variants/heltec_tracker_v2/HeltecTrackerV2Board.h b/variants/heltec_tracker_v2/HeltecTrackerV2Board.h index 5fed2b65559..091a39d651e 100644 --- a/variants/heltec_tracker_v2/HeltecTrackerV2Board.h +++ b/variants/heltec_tracker_v2/HeltecTrackerV2Board.h @@ -23,7 +23,7 @@ class HeltecTrackerV2Board : public ESP32Board { void onBeforeTransmit(void) override; void onAfterTransmit(void) override; - void powerOff() override; + void shutdownPeripherals() override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override ; diff --git a/variants/heltec_v4/HeltecV4Board.cpp b/variants/heltec_v4/HeltecV4Board.cpp index 794257d76ad..948c3dfaa12 100644 --- a/variants/heltec_v4/HeltecV4Board.cpp +++ b/variants/heltec_v4/HeltecV4Board.cpp @@ -32,12 +32,12 @@ void HeltecV4Board::begin() { loRaFEMControl.setRxModeEnable(); } - void HeltecV4Board::powerOff() { + void HeltecV4Board::shutdownPeripherals() { + ESP32Board::shutdownPeripherals(); // Turn off PA digitalWrite(P_LORA_PA_POWER, LOW); rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER); - ESP32Board::powerOff(); } uint16_t HeltecV4Board::getBattMilliVolts() { diff --git a/variants/heltec_v4/HeltecV4Board.h b/variants/heltec_v4/HeltecV4Board.h index 79a690e8407..1f83eee7c5b 100644 --- a/variants/heltec_v4/HeltecV4Board.h +++ b/variants/heltec_v4/HeltecV4Board.h @@ -29,7 +29,7 @@ class HeltecV4Board : public ESP32Board { void onBeforeTransmit(void) override; void onAfterTransmit(void) override; - void powerOff() override; + void shutdownPeripherals() override; uint16_t getBattMilliVolts() override; bool setAdcMultiplier(float multiplier) override { if (multiplier == 0.0f) { diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.cpp b/variants/heltec_v4_r8/HeltecV4R8Board.cpp index ea2cfbedb4a..31ef3a15b17 100644 --- a/variants/heltec_v4_r8/HeltecV4R8Board.cpp +++ b/variants/heltec_v4_r8/HeltecV4R8Board.cpp @@ -39,30 +39,12 @@ void HeltecV4R8Board::onAfterTransmit(void) { loRaFEMControl.setRxModeEnable(); } -void HeltecV4R8Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - loRaFEMControl.setRxModeEnableWhenMCUSleep(); - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup((1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); - } else { - esp_sleep_enable_ext1_wakeup((1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - esp_deep_sleep_start(); -} - -void HeltecV4R8Board::powerOff() { - enterDeepSleep(0); +void HeltecV4R8Board::shutdownPeripherals() { + ESP32Board::shutdownPeripherals(); + loRaFEMControl.setSleepModeEnable(); + digitalWrite(P_LORA_PA_POWER, LOW); + rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER); + rtc_gpio_hold_en((gpio_num_t)P_LORA_KCT8103L_PA_CSD); } uint16_t HeltecV4R8Board::getBattMilliVolts() { diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.h b/variants/heltec_v4_r8/HeltecV4R8Board.h index 7ab29f96200..03cbf10dbc4 100644 --- a/variants/heltec_v4_r8/HeltecV4R8Board.h +++ b/variants/heltec_v4_r8/HeltecV4R8Board.h @@ -30,8 +30,7 @@ class HeltecV4R8Board : public ESP32Board { void onBeforeTransmit(void) override; void onAfterTransmit(void) override; - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); - void powerOff() override; + void shutdownPeripherals() override; uint16_t getBattMilliVolts() override; bool setAdcMultiplier(float multiplier) override { if (multiplier == 0.0f) { diff --git a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp index e041995546d..f394b19463c 100644 --- a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp +++ b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp @@ -82,7 +82,8 @@ const char* TBeam1WBoard::getManufacturerName() const { return "LilyGo T-Beam 1W"; } -void TBeam1WBoard::powerOff() { +void TBeam1WBoard::shutdownPeripherals() { + ESP32Board::shutdownPeripherals(); portENTER_CRITICAL(&_fan_mux); _stopped = true; _tx_active = false; @@ -99,7 +100,6 @@ void TBeam1WBoard::powerOff() { digitalWrite(LED_PIN, LOW); - ESP32Board::powerOff(); } void TBeam1WBoard::setFanEnabled(bool enabled) { diff --git a/variants/lilygo_tbeam_1w/TBeam1WBoard.h b/variants/lilygo_tbeam_1w/TBeam1WBoard.h index 429b7bd1cfb..33af410a8f8 100644 --- a/variants/lilygo_tbeam_1w/TBeam1WBoard.h +++ b/variants/lilygo_tbeam_1w/TBeam1WBoard.h @@ -70,7 +70,7 @@ class TBeam1WBoard : public ESP32Board { void onAfterTransmit() override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override; - void powerOff() override; + void shutdownPeripherals() override; void setFanEnabled(bool enabled); bool isFanEnabled() const; diff --git a/variants/meshtiny/MeshtinyBoard.h b/variants/meshtiny/MeshtinyBoard.h index 21673d0aac8..c3a849037f3 100644 --- a/variants/meshtiny/MeshtinyBoard.h +++ b/variants/meshtiny/MeshtinyBoard.h @@ -34,7 +34,8 @@ class MeshtinyBoard : public NRF52BoardDCDC { void reboot() override { NVIC_SystemReset(); } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); #ifdef PIN_USER_BTN while (digitalRead(PIN_USER_BTN) == LOW) { @@ -60,7 +61,6 @@ class MeshtinyBoard : public NRF52BoardDCDC { nrf_gpio_cfg_sense_input(g_ADigitalPinMap[PIN_USER_BTN], NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); #endif - NRF52Board::powerOff(); } }; diff --git a/variants/meshtracker_x1/MeshTrackerX1Board.h b/variants/meshtracker_x1/MeshTrackerX1Board.h index 51f022e0b85..ddc738c253d 100644 --- a/variants/meshtracker_x1/MeshTrackerX1Board.h +++ b/variants/meshtracker_x1/MeshTrackerX1Board.h @@ -46,7 +46,8 @@ class MeshTrackerX1Board : public NRF52BoardDCDC { return 0; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); #ifdef HAS_GPS digitalWrite(GPS_VRTC_EN, LOW); digitalWrite(GPS_RESET, LOW); @@ -82,6 +83,5 @@ class MeshTrackerX1Board : public NRF52BoardDCDC { nrf_gpio_cfg_sense_input(BUTTON_PIN, NRF_GPIO_PIN_PULLDOWN, NRF_GPIO_PIN_SENSE_HIGH); #endif - sd_power_system_off(); } }; diff --git a/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h b/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h index 450a3d13b02..fa5632a94d0 100644 --- a/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h +++ b/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h @@ -45,7 +45,8 @@ class MinewsemiME25LS01Board : public NRF52BoardDCDC { return "Minewsemi"; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); #ifdef HAS_GPS digitalWrite(GPS_VRTC_EN, LOW); digitalWrite(GPS_RESET, LOW); @@ -65,7 +66,6 @@ class MinewsemiME25LS01Board : public NRF52BoardDCDC { #ifdef BUTTON_PIN nrf_gpio_cfg_sense_input(digitalPinToInterrupt(BUTTON_PIN), NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); #endif - NRF52Board::powerOff(); } #if defined(P_LORA_TX_LED) diff --git a/variants/muziworks_r1_neo/R1NeoBoard.h b/variants/muziworks_r1_neo/R1NeoBoard.h index c56f1f5c2e9..fa49a18439a 100644 --- a/variants/muziworks_r1_neo/R1NeoBoard.h +++ b/variants/muziworks_r1_neo/R1NeoBoard.h @@ -20,6 +20,7 @@ class R1NeoBoard : public NRF52BoardDCDC { #ifdef NRF52_POWER_MANAGEMENT void powerOff() override { + shutdownPeripherals(); initiateShutdown(SHUTDOWN_REASON_USER); } #endif diff --git a/variants/nano_g2_ultra/nano-g2.h b/variants/nano_g2_ultra/nano-g2.h index bf9543b039c..9e512adca98 100644 --- a/variants/nano_g2_ultra/nano-g2.h +++ b/variants/nano_g2_ultra/nano-g2.h @@ -43,7 +43,8 @@ class NanoG2Ultra : public NRF52Board { const char *getManufacturerName() const override { return "Nano G2 Ultra"; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); // put GPS chip to sleep digitalWrite(PIN_GPS_STANDBY, LOW); // TODO: unset buzzer to prevent notification circuit activating on hibernate @@ -52,6 +53,5 @@ class NanoG2Ultra : public NRF52Board { nrf_gpio_cfg_sense_input(digitalPinToInterrupt(PIN_USER_BTN), NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_SENSE_LOW); - NRF52Board::powerOff(); } }; diff --git a/variants/rak_wismesh_tag/RAKWismeshTagBoard.h b/variants/rak_wismesh_tag/RAKWismeshTagBoard.h index daa90d746a1..fafe571dc0f 100644 --- a/variants/rak_wismesh_tag/RAKWismeshTagBoard.h +++ b/variants/rak_wismesh_tag/RAKWismeshTagBoard.h @@ -40,7 +40,8 @@ class RAKWismeshTagBoard : public NRF52BoardDCDC { return "RAK WisMesh Tag"; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); #ifdef BUZZER_EN digitalWrite(BUZZER_EN, LOW); #endif @@ -69,6 +70,5 @@ class RAKWismeshTagBoard : public NRF52BoardDCDC { nrf_gpio_cfg_sense_input(digitalPinToInterrupt(BUTTON_PIN), NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); #endif - NRF52Board::powerOff(); } }; diff --git a/variants/sensecap_solar/SenseCapSolarBoard.h b/variants/sensecap_solar/SenseCapSolarBoard.h index 5a8861c298e..c16d6b0bbd3 100644 --- a/variants/sensecap_solar/SenseCapSolarBoard.h +++ b/variants/sensecap_solar/SenseCapSolarBoard.h @@ -37,7 +37,8 @@ class SenseCapSolarBoard : public NRF52BoardDCDC { return "Seeed SenseCap Solar"; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); digitalWrite(LED_WHITE, LOW); digitalWrite(LED_BLUE, LOW); @@ -51,7 +52,11 @@ class SenseCapSolarBoard : public NRF52BoardDCDC { nrf_gpio_cfg_sense_input(digitalPinToInterrupt(g_ADigitalPinMap[PIN_BUTTON1]), NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); #endif + } + + void powerOff() override { #ifdef NRF52_POWER_MANAGEMENT + shutdownPeripherals(); initiateShutdown(SHUTDOWN_REASON_USER); #else NRF52Board::powerOff(); diff --git a/variants/station_g3_esp32/StationG3Board.cpp b/variants/station_g3_esp32/StationG3Board.cpp index 277c2b8b767..d2c1a57ab51 100644 --- a/variants/station_g3_esp32/StationG3Board.cpp +++ b/variants/station_g3_esp32/StationG3Board.cpp @@ -1,6 +1,7 @@ #include "StationG3Board.h" -void StationG3Board::powerOff() { +void StationG3Board::shutdownPeripherals() { + ESP32Board::shutdownPeripherals(); loRaFEMControl.setSleepModeEnable(); #ifdef P_PA1_EN rtc_gpio_hold_en((gpio_num_t)P_PA1_EN); @@ -10,7 +11,6 @@ void StationG3Board::powerOff() { rtc_gpio_hold_en((gpio_num_t)P_PRIMARY_LNA_EN); #endif - ESP32Board::powerOff(); } bool StationG3Board::setLoRaFemLnaEnabled(bool enable) { diff --git a/variants/station_g3_esp32/StationG3Board.h b/variants/station_g3_esp32/StationG3Board.h index b4c5838c8aa..ca22ff23358 100644 --- a/variants/station_g3_esp32/StationG3Board.h +++ b/variants/station_g3_esp32/StationG3Board.h @@ -53,7 +53,7 @@ class StationG3Board : public ESP32Board { loRaFEMControl.setRxModeEnable(); } - void powerOff() override; + void shutdownPeripherals() override; uint16_t getBattMilliVolts() override { return 0; diff --git a/variants/t1000-e/T1000eBoard.h b/variants/t1000-e/T1000eBoard.h index ba8bf0c1d9d..db364dc1403 100644 --- a/variants/t1000-e/T1000eBoard.h +++ b/variants/t1000-e/T1000eBoard.h @@ -49,7 +49,8 @@ class T1000eBoard : public NRF52BoardDCDC { return 0; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); #ifdef HAS_GPS digitalWrite(GPS_VRTC_EN, LOW); digitalWrite(GPS_RESET, LOW); @@ -84,6 +85,5 @@ class T1000eBoard : public NRF52BoardDCDC { nrf_gpio_cfg_sense_input(BUTTON_PIN, NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_SENSE_HIGH); #endif - NRF52Board::powerOff(); } }; diff --git a/variants/thinknode_m1/ThinkNodeM1Board.h b/variants/thinknode_m1/ThinkNodeM1Board.h index 94224d9c2b9..2bf78b709e5 100644 --- a/variants/thinknode_m1/ThinkNodeM1Board.h +++ b/variants/thinknode_m1/ThinkNodeM1Board.h @@ -32,7 +32,8 @@ class ThinkNodeM1Board : public NRF52Board { return "Elecrow ThinkNode-M1"; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); // turn off all leds, sd_power_system_off will not do this for us #ifdef P_LORA_TX_LED @@ -40,6 +41,5 @@ class ThinkNodeM1Board : public NRF52Board { #endif // power off board - NRF52Board::powerOff(); } }; diff --git a/variants/thinknode_m3/ThinkNodeM3Board.h b/variants/thinknode_m3/ThinkNodeM3Board.h index 9e8f49894d2..1e8dd32a448 100644 --- a/variants/thinknode_m3/ThinkNodeM3Board.h +++ b/variants/thinknode_m3/ThinkNodeM3Board.h @@ -42,13 +42,13 @@ class ThinkNodeM3Board : public NRF52BoardDCDC { return 0; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); // turn off all leds, sd_power_system_off will not do this for us digitalWrite(PIN_LED_BLUE, !LED_STATE_ON); digitalWrite(PIN_LED_GREEN, !LED_STATE_ON); digitalWrite(PIN_LED_RED, !LED_STATE_ON); // power off board - NRF52Board::powerOff(); } }; diff --git a/variants/thinknode_m6/ThinkNodeM6Board.h b/variants/thinknode_m6/ThinkNodeM6Board.h index 934794c9af4..1c8d70f4d20 100644 --- a/variants/thinknode_m6/ThinkNodeM6Board.h +++ b/variants/thinknode_m6/ThinkNodeM6Board.h @@ -65,7 +65,8 @@ class ThinkNodeM6Board : public NRF52BoardDCDC { return isSolarChargerActive() || isUsbPowered(); } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); // turn off all leds, sd_power_system_off will not do this for us #ifdef P_LORA_TX_LED @@ -73,6 +74,5 @@ class ThinkNodeM6Board : public NRF52BoardDCDC { #endif // power off board - NRF52Board::powerOff(); } }; diff --git a/variants/wio-tracker-l1-1w/WioTrackerL1Board.h b/variants/wio-tracker-l1-1w/WioTrackerL1Board.h index f6164e47af1..4136d1feaaf 100644 --- a/variants/wio-tracker-l1-1w/WioTrackerL1Board.h +++ b/variants/wio-tracker-l1-1w/WioTrackerL1Board.h @@ -41,8 +41,8 @@ class WioTrackerL1Board : public NRF52BoardDCDC { return "Seeed Wio Tracker L1"; } - void powerOff() override { + void shutdownPeripherals() override { // powerOff() and timed enterDeepSleep() both come here setLoRaFemPaGainEnabled(false); // turn Off PA - NRF52Board::powerOff(); + NRF52Board::shutdownPeripherals(); } }; diff --git a/variants/wio-tracker-l1/WioTrackerL1Board.h b/variants/wio-tracker-l1/WioTrackerL1Board.h index 99e852bb7d2..f41beb6ef08 100644 --- a/variants/wio-tracker-l1/WioTrackerL1Board.h +++ b/variants/wio-tracker-l1/WioTrackerL1Board.h @@ -34,7 +34,4 @@ class WioTrackerL1Board : public NRF52BoardDCDC { return "Seeed Wio Tracker L1"; } - void powerOff() override { - NRF52Board::powerOff(); - } }; diff --git a/variants/xiao_nrf52/XiaoNrf52Board.h b/variants/xiao_nrf52/XiaoNrf52Board.h index e71e7f65177..91a0d5961f7 100644 --- a/variants/xiao_nrf52/XiaoNrf52Board.h +++ b/variants/xiao_nrf52/XiaoNrf52Board.h @@ -35,7 +35,8 @@ class XiaoNrf52Board : public NRF52BoardDCDC { return "Seeed Xiao-nrf52"; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); // set led on and wait for button release before poweroff digitalWrite(PIN_LED, LOW); #ifdef PIN_USER_BTN @@ -50,7 +51,6 @@ class XiaoNrf52Board : public NRF52BoardDCDC { nrf_gpio_cfg_sense_input(digitalPinToInterrupt(g_ADigitalPinMap[PIN_USER_BTN]), NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); #endif - NRF52Board::powerOff(); } }; From d49f307e787cee793b1b1055832eef8643e27834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Fri, 16 Oct 2026 00:17:12 +0200 Subject: [PATCH 31/44] rxps: RX duty-cycle calculator and CLI parser RX power saving (RXPS) keeps the radio asleep between short RX windows that are still long enough to catch a preamble. - Levels 1-10 with preamble 16 or 32 symbols. Levels 9 ("overdrive") and 10 ("riskyWorkingMax") run outside the datasheet timing rule. - Presets: on/conservative = 3, balanced = 6, max = 8. - Manual rx/sleep periods of 1000-30000000 us. - The calculator models the chip's preamble capture cost and rejects invalid SF/BW, NaN/Inf and overflowing periods. - A profile that does not fit the active modulation falls back to continuous RX, flags a timing error and keeps the saved intent, so stale periods are never armed. - RXPowerSavingCLI parses 'set radio.rxps ...' and formats 'get radio.rxps'. The 'catch' margin is shown only when the windows are valid and no error is pending. Pure logic with host tests in test_rx_power_saving; radios and roles use it in the following commits. From PowerSaving-v17 285cf10a and 6bbd34a2. --- platformio.ini | 2 + src/helpers/radiolib/RXPowerSaving.cpp | 245 +++++ src/helpers/radiolib/RXPowerSaving.h | 275 ++++++ src/helpers/radiolib/RXPowerSavingCLI.cpp | 268 ++++++ src/helpers/radiolib/RXPowerSavingCLI.h | 15 + .../test_rx_power_saving.cpp | 894 ++++++++++++++++++ 6 files changed, 1699 insertions(+) create mode 100644 src/helpers/radiolib/RXPowerSaving.cpp create mode 100644 src/helpers/radiolib/RXPowerSaving.h create mode 100644 src/helpers/radiolib/RXPowerSavingCLI.cpp create mode 100644 src/helpers/radiolib/RXPowerSavingCLI.h create mode 100644 test/test_rx_power_saving/test_rx_power_saving.cpp diff --git a/platformio.ini b/platformio.ini index 4699dd98f30..6fa29695e8e 100644 --- a/platformio.ini +++ b/platformio.ini @@ -176,6 +176,8 @@ build_src_filter = +<../src/Packet.cpp> +<../src/helpers/ConfigSerializer.cpp> +<../src/helpers/DynamicConfigSerializer.cpp> + +<../src/helpers/radiolib/RXPowerSaving.cpp> + +<../src/helpers/radiolib/RXPowerSavingCLI.cpp> lib_deps = google/googletest @ 1.17.0 diff --git a/src/helpers/radiolib/RXPowerSaving.cpp b/src/helpers/radiolib/RXPowerSaving.cpp new file mode 100644 index 00000000000..6a357390dfa --- /dev/null +++ b/src/helpers/radiolib/RXPowerSaving.cpp @@ -0,0 +1,245 @@ +#include "RXPowerSaving.h" +#include + +static uint32_t ceilRxPowerSavingValue(float value) { + uint32_t rounded = (uint32_t)value; + return value > (float)rounded ? rounded + 1 : rounded; +} + +// Both radios program their duty cycle in 15.625 us ticks and truncate the +// microsecond value on the way in, so a period of 10013 us is really 10000 us +// on air while the CLI happily reports 10013. Snapping to the nearest tick and +// returning the smallest microsecond value that still lands on it makes the +// reported periods the ones the hardware runs - which matters here, because the +// register value turned out to be what a duty-cycle defect keys on, not the +// microseconds we asked for. +static uint32_t rxPowerSavingTickToUs(uint64_t tick) { + if (tick == 0) tick = 1; + return (uint32_t)((tick * 125 + 7) / 8); // ceil(tick * 15.625) +} + +static uint32_t snapRxPowerSavingToTick(uint32_t us) { + return rxPowerSavingTickToUs(((uint64_t)us * 8 + 62) / 125); // nearest +} + +// Rounding the listen window to the *nearest* tick can shave a few microseconds +// off, and the timer guard below has no room to give: it must hold with the +// register values the radio actually runs. So that one rounds up. +static uint32_t snapRxPowerSavingUpToTick(uint32_t us) { + return rxPowerSavingTickToUs(((uint64_t)us * 8 + 124) / 125); // ceil +} + +bool isValidRxPowerSavingPeriod(uint32_t us) { + return us >= RX_POWERSAVING_MIN_PERIOD_US && us <= RX_POWERSAVING_MAX_PERIOD_US; +} + +uint8_t rxPowerSavingPreambleForSF(uint8_t sf) { + return sf <= 8 ? 32 : 16; +} + +bool isRxPowerSavingNumeric(const char* value) { + if (value == nullptr || *value == 0) return false; + while (*value) { + if (*value < '0' || *value > '9') return false; + value++; + } + return true; +} + +float rxPowerSavingCaptureCost(const RxPowerSavingControl* control) { + return control != nullptr ? control->rxPowerSavingCaptureCostSymbols() + : RX_POWERSAVING_CAPTURE_COST_SYMBOLS; +} + +uint32_t rxPowerSavingTransition(const RxPowerSavingControl* control) { + return control != nullptr ? control->rxPowerSavingTransitionUs() + : RX_POWERSAVING_TRANSITION_US; +} + +// rxPeriod register values measured to break SetRxDutyCycle on SX1262: the +// radio latches the preamble and then never validates a header, losing 35% of +// packets at SF6 and all of them at SF7. Both were reproduced on two boards +// with an LR1110 witnessing 100% of the same transmissions, and both bands are +// exactly one tick wide - 639 and 641 are lossless. +// +// This list is certainly incomplete: about thirty values were sampled out of +// the thousands a profile can generate. Levels 1-10 do not need it because the +// timer guard makes any value safe; it exists only for the unguarded maximum, +// where it is the one protection left. +static const uint32_t RX_POWERSAVING_BAD_RX_TICKS[] = {320, 640}; + +static uint32_t avoidBadRxPowerSavingTick(uint32_t rx_us) { + const uint32_t tick = (rx_us * 8) / 125; + for (uint32_t bad : RX_POWERSAVING_BAD_RX_TICKS) { + // One tick longer costs 15.625 us of listening and steps clear of the band. + if (tick == bad) return (uint32_t)((((uint64_t)tick + 1) * 125 + 7) / 8); + } + return rx_us; +} + +bool calcRxPowerSavingLevel(uint8_t level, uint8_t sf, float bw, uint8_t preamble, + uint32_t* rx_us, uint32_t* sleep_us, + float capture_cost_symbols, uint32_t transition_us) { + if (rx_us == nullptr || sleep_us == nullptr || level < 1 || + level > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL || sf < 5 || sf > 12 || + !isfinite(bw) || bw <= 0.0f || !isfinite(capture_cost_symbols) || + capture_cost_symbols <= 0 || capture_cost_symbols >= preamble || transition_us >= RX_POWERSAVING_MAX_PERIOD_US || (preamble != 16 && preamble != 32)) { + return false; + } + const bool unguarded = isRxPowerSavingUnguardedLevel(level); + + const float symbol_us = (1000.0f * (float)(1UL << sf)) / bw; + if (!isfinite(symbol_us) || symbol_us * 64.0f > (float)(UINT32_MAX / 8)) return false; + // Worst case a preamble starts the instant an RX window closes, so the part + // that survives into the next window is `preamble - sleep`. The absolute + // limit is therefore reached when that equals the chip's capture cost; the + // unguarded levels are pinned to geometries measured at or past that point, + // which is why they still need it. + const float sleep_edge_symbols = (float)preamble - capture_cost_symbols; + + float sleep_symbols; + float rx_symbols = 0.0f; // only used by the unguarded levels + if (unguarded) { + // Kept on the old interpolation because that is the geometry the bench + // measured: overdrive is the top of the profile, and `riskyWorkingMax` is + // the point past it where delivery started to fall. + float amount = 1.0f; + if (isRxPowerSavingRiskyWorkingMaxLevel(level)) { + const float virtual_level = preamble == 16 ? RX_POWERSAVING_RISKY_WORKING_MAX_VIRTUAL_P16 + : RX_POWERSAVING_RISKY_WORKING_MAX_VIRTUAL_P32; + amount = (virtual_level - 1.0f) / 9.0f; + } + const float rx_start_symbols = preamble == 16 ? 12.0f : 16.0f; + const float sleep_start_symbols = preamble == 16 ? 2.0f : 15.0f; + rx_symbols = rx_start_symbols + amount * (8.0f - rx_start_symbols); + sleep_symbols = sleep_start_symbols + amount * (sleep_edge_symbols - sleep_start_symbols); + } else { + // Guarded levels state their catch directly, so the sleep is just what is + // left of the preamble - no capture cost involved. The pad makes the node + // catch a fifth of a symbol more than promised, which is what keeps the + // generated register pairs off the defective ones. + const float catch_symbols = rxPowerSavingLevelCatch(level, preamble); + // A level that undertakes to catch less than this radio needs to latch is + // not usable: say so rather than return a geometry that cannot work. + if (catch_symbols < capture_cost_symbols) return false; + sleep_symbols = (float)preamble - catch_symbols - RX_POWERSAVING_MARGIN_PAD_SYMBOLS; + if (sleep_symbols < 0.0f) sleep_symbols = 0.0f; + } + + *sleep_us = (uint32_t)(sleep_symbols * symbol_us); + + // Below the hardware floor the duty cycle cannot be armed at all: the driver + // returns -708 and the wrapper quietly runs continuous RX, so the node keeps + // reporting power saving while spending full RX current. Just above that the + // SX1262 arms with ERR_NONE and then detects no preambles at all, which is + // worse. Measured floor: 6050-6100 us dead, 6200 us and up lossless. + const uint32_t min_sleep_us = transition_us + RX_POWERSAVING_MIN_SLEEP_MARGIN_US; + if (*sleep_us < min_sleep_us) { + if ((float)min_sleep_us > sleep_edge_symbols * symbol_us) { + // Even zero margin would not fit above the floor: no level of this + // profile can duty cycle at this SF/BW. Say so instead of returning + // something that will silently fall back. + return false; + } + // The floor eats into the margin. Levels below it collapse onto one point; + // the reply still reports the periods, so the loss of margin is visible. + *sleep_us = min_sleep_us; + } + *sleep_us = transition_us + snapRxPowerSavingToTick(*sleep_us - transition_us); + + // SetRxDutyCycle timer guard, straight out of the SX1261/2 datasheet: on + // preamble detection the radio restarts its timer with 2*rxPeriod + + // sleepPeriod (register values), and the packet dies if the header does not + // arrive inside it. The datasheet states the requirement as + // Tpreamble + Theader <= 2 * rxPeriod + sleepPeriod + // Measured: when it holds, no period value causes trouble. When it is broken + // the SX1262 usually gets away with it - but not always, and the exceptions + // are a single register tick wide. rxPeriod 640 (exactly 10.000 ms) loses + // 60-65% of packets at SF6 and everything at SF7, on two different boards, + // while 639 and 641 are lossless; rxPeriod 320 behaves the same way. Nothing + // in the register value predicts which ticks misbehave, so satisfy the + // condition instead of dodging the values. The listen window is therefore not + // a free parameter on the guarded scale - it is whatever the condition needs. + const float sync_symbols = + sf <= 6 ? RX_POWERSAVING_SYNC_SYMBOLS_LOW_SF : RX_POWERSAVING_SYNC_SYMBOLS; + const float need_us = + ((float)preamble + sync_symbols + RX_POWERSAVING_HEADER_SYMBOLS + + RX_POWERSAVING_TIMER_SLACK_SYMBOLS) * symbol_us; + // Work in the radio's own units: the driver truncates (sleep - transition) to + // 15.625 us ticks, so the microsecond difference overstates the programmed + // sleep by up to one tick - enough to leave the condition short on every + // level of a profile. + const uint32_t sleep_ticks = ((*sleep_us - transition_us) * 8) / 125; + const float programmed_sleep_us = (float)sleep_ticks * RX_POWERSAVING_TICK_US; + + if (unguarded) { + *rx_us = ceilRxPowerSavingValue(rx_symbols * symbol_us); + } else { + const float min_rx_us = (need_us - programmed_sleep_us) / 2.0f; + *rx_us = ceilRxPowerSavingValue(min_rx_us > 0.0f ? min_rx_us : symbol_us); + } + + *rx_us = snapRxPowerSavingUpToTick(*rx_us); + if (unguarded) *rx_us = avoidBadRxPowerSavingTick(*rx_us); + return true; +} + +void ensureRxPowerSavingDefaults(uint32_t* rx_us, uint32_t* sleep_us) { + if (!isValidRxPowerSavingPeriod(*rx_us)) *rx_us = RX_POWERSAVING_DEFAULT_RX_US; + if (!isValidRxPowerSavingPeriod(*sleep_us)) *sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US; +} + +bool recalcRxPowerSavingFromLevel(uint8_t level, uint8_t sf, float bw, uint8_t preamble, + uint32_t* rx_us, uint32_t* sleep_us, + float capture_cost_symbols, uint32_t transition_us) { + if (level < 1 || level > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL) return false; + if (preamble == 0) preamble = rxPowerSavingPreambleForSF(sf); + + uint32_t calculated_rx_us = 0; + uint32_t calculated_sleep_us = 0; + if (!calcRxPowerSavingLevel(level, sf, bw, preamble, &calculated_rx_us, &calculated_sleep_us, + capture_cost_symbols, transition_us) || + !isValidRxPowerSavingPeriod(calculated_rx_us) || + !isValidRxPowerSavingPeriod(calculated_sleep_us)) { + return false; + } + + *rx_us = calculated_rx_us; + *sleep_us = calculated_sleep_us; + return true; +} + +bool normalizeRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw, + float capture_cost_symbols, uint32_t transition_us) { + if (config == nullptr || sf < 5 || sf > 12 || !isfinite(bw) || bw <= 0) return false; + RxPowerSavingConfig proposed = *config; + proposed.enabled = proposed.enabled ? 1 : 0; + if (proposed.level > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL) { + proposed.level = RX_POWERSAVING_BALANCED_LEVEL; + } + if (proposed.preamble != 0 && proposed.preamble != 16 && proposed.preamble != 32) { + proposed.preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + } + if (proposed.level == 0) { + if (!isValidRxPowerSavingPeriod(proposed.rx_us) || + !isValidRxPowerSavingPeriod(proposed.sleep_us)) return false; + } else if (!recalcRxPowerSavingFromLevel(proposed.level, sf, bw, proposed.preamble, + &proposed.rx_us, &proposed.sleep_us, + capture_cost_symbols, transition_us)) { + return false; + } + *config = proposed; + return true; +} + +bool applyRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw, + RxPowerSavingControl* control) { + if (config == nullptr) return false; + const bool valid = !config->enabled || normalizeRxPowerSavingConfig( + config, sf, bw, rxPowerSavingCaptureCost(control), rxPowerSavingTransition(control)); + if (control == nullptr) return !config->enabled; + const bool accepted = control->setRxPowerSaving( + config->enabled && valid, config->rx_us, config->sleep_us); + control->setRxPowerSavingConfigError(!valid); + return valid && accepted; +} diff --git a/src/helpers/radiolib/RXPowerSaving.h b/src/helpers/radiolib/RXPowerSaving.h new file mode 100644 index 00000000000..5f52560e42f --- /dev/null +++ b/src/helpers/radiolib/RXPowerSaving.h @@ -0,0 +1,275 @@ +#pragma once + +#include + +static constexpr uint32_t RX_POWERSAVING_DEFAULT_RX_US = 65625UL; +static constexpr uint32_t RX_POWERSAVING_DEFAULT_SLEEP_US = 60000UL; +static constexpr uint32_t RX_POWERSAVING_MIN_PERIOD_US = 1000UL; +static constexpr uint32_t RX_POWERSAVING_MAX_PERIOD_US = 30000000UL; + +// The level scale is a catch dial, in symbols of the sender's preamble. +// +// Level N means: "at least this many symbols of the sender's preamble land +// inside an open RX window." That is the quantity the radio actually needs - +// a preamble is latched once enough of it has been heard - so the level states +// the requirement directly instead of stating a distance from an edge. +// +// The arithmetic follows from that in one step. Worst case, a preamble starts +// the instant an RX window closes, so what survives into the next window is +// `preamble - sleep`. Requiring that to be at least the level's catch gives +// +// sleep = preamble - catch +// +// and the chip's capture cost never enters the geometry. It becomes a +// validation instead: a level is usable when its catch is at least the cost. +// The floor of the scale is 8 symbols precisely because that is what the +// slower of the two families needs (LR11x0 8, SX126x 6), so **every level +// means the same geometry on both radios** - which the previous margin-based +// scale could not do, because the cost sat inside its formula. +// +// The two profiles get their own ladders. On a 16-symbol preamble there are +// only 8 symbols to spend between the floor and the whole preamble, so the +// steps are single symbols; on a 32-symbol preamble there are 24, so the low +// levels can take much larger strides where reception is guaranteed anyway. +static constexpr uint8_t RX_POWERSAVING_GUARDED_LEVELS = 8; +static constexpr float RX_POWERSAVING_LEVEL_CATCH_P16[RX_POWERSAVING_GUARDED_LEVELS] = { + 15.0f, 14.0f, 13.0f, 12.0f, 11.0f, 10.0f, 9.0f, 8.0f +}; +static constexpr float RX_POWERSAVING_LEVEL_CATCH_P32[RX_POWERSAVING_GUARDED_LEVELS] = { + 24.0f, 20.0f, 16.0f, 14.0f, 12.0f, 10.0f, 9.0f, 8.0f +}; + +// Smallest catch any guarded level asks for, and the reason it is 8: the +// LR11x0 needs 8 symbols to latch where the SX126x needs 6. +static constexpr float RX_POWERSAVING_MIN_CATCH_SYMBOLS = 8.0f; + +// On top of the ladder, every guarded level sleeps this much less than the +// arithmetic demands - so it catches 0.2 symbols more than its level promises. +// The reason is not capture safety: it is that specific register pairs the +// calculation lands on are defective (preamble latched, header never +// validated) in the same way isolated rxPeriod ticks 320 and 640 are. Nudging +// every generated geometry off those points cost 0.05 mA and took the worst +// measured cell from 3.33% packet loss to 0.11% across the whole ladder. +// Levels 9 and 10 are exempt - their geometries are pinned to bench +// measurements and shifting them would invalidate that data. +static constexpr float RX_POWERSAVING_MARGIN_PAD_SYMBOLS = 0.2f; + +// Past the guarded scale sit two settings that break the datasheet's +// SetRxDutyCycle timer condition on purpose. Overdrive is lossless and 4-12 +// percentage points cheaper in duty cycle; `riskyWorkingMax` is the measured +// working edge and drops ~2% of packets by design. +static constexpr uint8_t RX_POWERSAVING_OVERDRIVE_LEVEL = 9; +static constexpr uint8_t RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL = 10; +static constexpr float RX_POWERSAVING_RISKY_WORKING_MAX_VIRTUAL_P32 = 11.0f; +static constexpr float RX_POWERSAVING_RISKY_WORKING_MAX_VIRTUAL_P16 = 10.25f; + +// Top of the guarded scale: catches the ladder floor of 8 symbols (8.2 with +// the pad), the cheapest setting that still meets the datasheet condition and +// still works on both radio families. The CLI calls it `max`. +static constexpr uint8_t RX_POWERSAVING_MAX_LEVEL = RX_POWERSAVING_GUARDED_LEVELS; + +static constexpr uint8_t RX_POWERSAVING_CONSERVATIVE_LEVEL = 3; // catches 13 (P16) / 16 (P32) +static constexpr uint8_t RX_POWERSAVING_BALANCED_LEVEL = 6; // catches 10 (P16) / 10 (P32) +static constexpr uint8_t RX_POWERSAVING_PROFILE_PREAMBLE = 16; +static constexpr uint8_t RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES = 3; + +// Symbols of a sender's preamble consumed before a packet can be latched: +// ~2 to latch the preamble plus ~2 lost to RX-window startup, measured on +// SX1262 at SF8/BW62.5. Capture needs sleep <= sender_preamble - this. +// Note this is well below the 8 symbols RadioLib quotes (SX126x.h) - that +// figure is the datasheet's margin for *reliable* latching, not the floor. +// +// Chip dependent, so it belongs with the driver rather than with each board: +// a wrapper overrides rxPowerSavingCaptureCostSymbols() and every variant using +// that radio picks it up. LR1110 needs about one symbol more - on the bench it +// held 300/300 at a margin of 7.78 symbols and started dropping packets at +// 6.89, for both profiles, where SX1262 is clean down to 6. +static constexpr float RX_POWERSAVING_CAPTURE_COST_SYMBOLS = 6.0f; +static constexpr float RX_POWERSAVING_CAPTURE_COST_SYMBOLS_LR11X0 = 8.0f; + +// TCXO startup delay handed to SetDIO3AsTCXOCtrl (SX126x DS 13.3.6) / +// SetTcxoMode (LR11x0 UM 6.3.2). RadioLib defaults it to 5000 us and nothing +// overrode it, so every board paid 5 ms on every duty-cycle wake whatever +// crystal was fitted. Measured lowest working value on five modules: T096 400, +// Tracker V2 300, Waveshare 150, ThinkNode M3 200, T1000-E 200 us - so 1600 +// keeps 4x margin over the worst of them. +// +// One knob for the whole tree, in `arduino_base`, because it is a property of +// the parts MeshCore is built with rather than of any one board. A variant that +// needs its own value defines it in its own build_flags and the later -D wins. +#ifndef MC_TCXO_DELAY_US + #define MC_TCXO_DELAY_US 1600 +#endif + +// Sleep -> RX transition: RadioLib subtracts tcxoDelay + 1000 us from the +// requested sleep before writing the register, and the hardware spends that +// long saving context, restarting the XTAL and locking the PLL. Everything +// below derives from it, so a board that knows its real TCXO delay overrides +// one method and both the sleep floor and the timer guard follow. +static constexpr uint32_t RX_POWERSAVING_TRANSITION_US = MC_TCXO_DELAY_US + 1000; + +// Slack the timer guard leaves on top of the datasheet condition, in symbols. +// +// Closing the condition exactly is not enough. The calculator lands on register +// pairs that satisfy it by a hair and still lose packets: rx=2476/sleep=2455 +// ticks clears it by 22 us at 0.12 symbol and drops 2-6% on every SX1262 tried, +// while moving any one component clears the fault. It is the same family as the +// isolated bad ticks 320 and 640 - the condition does not predict which pairs +// misbehave, so the answer is to stop generating geometries that sit on its +// edge. One whole symbol of slack is what the bench evidence supports. +#ifndef MC_RXPS_TIMER_SLACK_SYMBOLS + #define MC_RXPS_TIMER_SLACK_SYMBOLS 1.0f +#endif +static constexpr float RX_POWERSAVING_TIMER_SLACK_SYMBOLS = MC_RXPS_TIMER_SLACK_SYMBOLS; + +// Both radio families program their duty cycle in these units, and truncate the +// microsecond value on the way in. Everything the timer guard compares has to +// be expressed in whole ticks or it comes out short by a fraction of one. +static constexpr float RX_POWERSAVING_TICK_US = 15.625f; + +// Margin above the transition before a duty cycle actually works; see below. +static constexpr uint32_t RX_POWERSAVING_MIN_SLEEP_MARGIN_US = 250; + +// Symbols between the end of the sender's preamble and the end of the header: +// the LoRa sync word plus the 8-symbol explicit header. SF5/SF6 use a longer +// sync word than SF7 and above. Used by the SetRxDutyCycle timer guard. +static constexpr float RX_POWERSAVING_SYNC_SYMBOLS_LOW_SF = 6.25f; +static constexpr float RX_POWERSAVING_SYNC_SYMBOLS = 4.25f; +static constexpr float RX_POWERSAVING_HEADER_SYMBOLS = 8.0f; + +// Shortest sleep that actually produces a working duty cycle. +// +// RadioLib subtracts tcxoDelay + 1000 us from the requested sleep before +// writing the register. At or below that the register underflows and arming +// fails with RADIOLIB_ERR_INVALID_SLEEP_PERIOD (-708), after which the wrapper +// falls back to continuous RX while the config still says power saving is on. +// Measured on SX1262: the P16 profile fails to arm on levels 1-5 at SF6 and on +// levels 1-2 at SF7, exactly where that bound predicts. +// +// But arming successfully is NOT the boundary. Just above it the SX1262 accepts +// the command, returns ERR_NONE, and then detects **zero** preambles - a +// silently deaf receiver, which is worse than the honest -708. Measured twice, +// on two different SF and RX windows: sleep 6050-6100 us is dead, 6200 us and +// everything above it is lossless (40/40 at every value up to 21617 us). The +// chip needs roughly 200 us of *programmed* sleep, i.e. ~12 register ticks, so +// this leaves 250 us of margin on top of the transition time. +// +// The default assumes the 5000 us TCXO delay RadioLib installs; a wrapper that +// can read its radio overrides this with the board's real value. +static constexpr uint32_t RX_POWERSAVING_MIN_SLEEP_US = + RX_POWERSAVING_TRANSITION_US + RX_POWERSAVING_MIN_SLEEP_MARGIN_US; + +class RxPowerSavingArmRetryState { + uint8_t _consecutive_failures = 0; + +public: + bool canAttempt() const { + return _consecutive_failures < RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES; + } + void recordSuccess() { _consecutive_failures = 0; } + void recordFailure() { + if (_consecutive_failures < RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES) { + _consecutive_failures++; + } + } + void reset() { _consecutive_failures = 0; } + uint8_t consecutiveFailures() const { return _consecutive_failures; } +}; + +struct RxPowerSavingConfig { + uint8_t enabled = 0; + uint32_t rx_us = RX_POWERSAVING_DEFAULT_RX_US; + uint32_t sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US; + uint8_t level = RX_POWERSAVING_BALANCED_LEVEL; + uint8_t preamble = RX_POWERSAVING_PROFILE_PREAMBLE; +}; + +struct RxPowerSavingStatus { + bool supported = false; + bool armed = false; + int16_t last_error = 0; + uint32_t arm_failures = 0; + // Periods the radio actually runs with. A driver may clamp the requested + // values to satisfy a hardware constraint, so these can differ from the + // configured rx_us/sleep_us. Zero means "nothing armed yet". + uint32_t effective_rx_us = 0; + uint32_t effective_sleep_us = 0; +}; + +class RxPowerSavingControl { +public: + virtual ~RxPowerSavingControl() = default; + + virtual bool setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) { + (void)rx_us; + (void)sleep_us; + return !enabled; + } + virtual RxPowerSavingStatus getRxPowerSavingStatus() const { return {}; } + virtual void setRxPowerSavingConfigError(bool invalid) { (void)invalid; } + virtual bool isRxPowerSavingCalibrationActive() const { return false; } + + // Symbols of the sender's preamble consumed before a packet can be latched. + // Overridden per radio family; see RX_POWERSAVING_CAPTURE_COST_SYMBOLS. + virtual float rxPowerSavingCaptureCostSymbols() const { + return RX_POWERSAVING_CAPTURE_COST_SYMBOLS; + } + + // Sleep -> RX transition of this radio, in microseconds. The sleep floor and + // the SetRxDutyCycle timer guard are both derived from it, so a wrapper that + // can read its own tcxoDelay only needs to override this one method. + virtual uint32_t rxPowerSavingTransitionUs() const { + return RX_POWERSAVING_TRANSITION_US; + } +}; + +// Margin this level asks for, in symbols. Only meaningful on the guarded scale. +// Symbols of the sender's preamble this level undertakes to catch. The two +// profiles have separate ladders, so the profile has to be passed in; anything +// other than 16 or 32 is treated as the 16-symbol profile, matching the +// validation in calcRxPowerSavingLevel(). +inline float rxPowerSavingLevelCatch(uint8_t level, uint8_t preamble) { + if (level < 1 || level > RX_POWERSAVING_GUARDED_LEVELS) return 0.0f; + return preamble == 32 ? RX_POWERSAVING_LEVEL_CATCH_P32[level - 1] + : RX_POWERSAVING_LEVEL_CATCH_P16[level - 1]; +} + +inline bool isRxPowerSavingOverdriveLevel(uint8_t level) { + return level == RX_POWERSAVING_OVERDRIVE_LEVEL; +} + +inline bool isRxPowerSavingRiskyWorkingMaxLevel(uint8_t level) { + return level == RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL; +} + +// Both levels past the guarded scale run outside the datasheet's timer +// condition. Worth surfacing wherever a level is reported. +inline bool isRxPowerSavingUnguardedLevel(uint8_t level) { + return level >= RX_POWERSAVING_OVERDRIVE_LEVEL; +} + +bool isValidRxPowerSavingPeriod(uint32_t us); +uint8_t rxPowerSavingPreambleForSF(uint8_t sf); +bool isRxPowerSavingNumeric(const char* value); +// capture_cost_symbols defaults to the SX126x figure so callers without a radio +// handy still compile; anything holding a RxPowerSavingControl should pass +// rxPowerSavingCaptureCost(control) instead. +float rxPowerSavingCaptureCost(const RxPowerSavingControl* control); +uint32_t rxPowerSavingTransition(const RxPowerSavingControl* control); +bool calcRxPowerSavingLevel(uint8_t level, uint8_t sf, float bw, uint8_t preamble, + uint32_t* rx_us, uint32_t* sleep_us, + float capture_cost_symbols = RX_POWERSAVING_CAPTURE_COST_SYMBOLS, + uint32_t transition_us = RX_POWERSAVING_TRANSITION_US); +void ensureRxPowerSavingDefaults(uint32_t* rx_us, uint32_t* sleep_us); +bool recalcRxPowerSavingFromLevel(uint8_t level, uint8_t sf, float bw, uint8_t preamble, + uint32_t* rx_us, uint32_t* sleep_us, + float capture_cost_symbols = RX_POWERSAVING_CAPTURE_COST_SYMBOLS, + uint32_t transition_us = RX_POWERSAVING_TRANSITION_US); +bool normalizeRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw, + float capture_cost_symbols = RX_POWERSAVING_CAPTURE_COST_SYMBOLS, + uint32_t transition_us = RX_POWERSAVING_TRANSITION_US); + +// The requested configuration survives a timing failure; only the radio falls back. +bool applyRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw, + RxPowerSavingControl* control); +static constexpr int16_t RX_POWERSAVING_ERR_TIMING = -10001; diff --git a/src/helpers/radiolib/RXPowerSavingCLI.cpp b/src/helpers/radiolib/RXPowerSavingCLI.cpp new file mode 100644 index 00000000000..d9b9b75114a --- /dev/null +++ b/src/helpers/radiolib/RXPowerSavingCLI.cpp @@ -0,0 +1,268 @@ +#include "RXPowerSavingCLI.h" + +#include +#include +#include +#include +#include + +// Suffix printed after the level so an unguarded profile is never mistaken for +// an ordinary one, in both the `set` echo and the `get` report. +static const char* rxPowerSavingLevelTag(uint8_t level) { + if (isRxPowerSavingRiskyWorkingMaxLevel(level)) return "(riskyWorkingMax)"; + if (isRxPowerSavingOverdriveLevel(level)) return "(overdrive)"; + return ""; +} + +static bool parseRxPowerSavingUint32(const char* value, uint32_t* parsed) { + if (parsed == nullptr || !isRxPowerSavingNumeric(value)) return false; + + uint32_t result = 0; + while (*value) { + uint32_t digit = (uint32_t)(*value++ - '0'); + if (result > (UINT32_MAX - digit) / 10U) return false; + result = result * 10U + digit; + } + *parsed = result; + return true; +} + +bool RXPowerSavingCLI::set(const char* value, uint8_t sf, float bw, + RxPowerSavingConfig* config, RxPowerSavingControl* control, + char* reply, size_t reply_size) { + if (value == nullptr || config == nullptr || reply == nullptr || reply_size == 0) return false; + + RxPowerSavingConfig proposed = *config; + uint8_t level = 0; + uint8_t preamble = rxPowerSavingPreambleForSF(sf); + bool level_requested = false; + bool preamble_overridden = false; + + ensureRxPowerSavingDefaults(&proposed.rx_us, &proposed.sleep_us); + + if (strcmp(value, "off") == 0) { + proposed.enabled = 0; + } else if (strcmp(value, "on") == 0 || strcmp(value, "conservative") == 0) { + proposed.enabled = 1; + level = RX_POWERSAVING_CONSERVATIVE_LEVEL; + preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + level_requested = true; + preamble_overridden = true; + } else if (strcmp(value, "max") == 0) { + // Top of the guarded scale. Like every named preset it assumes the worst + // case a mixed network can present - a sender using a 16-symbol preamble - + // so at SF<=8 it is markedly less economical than `level 10`, which follows + // the SF and picks the 32-symbol profile. `max preamble 32` gets that back. + proposed.enabled = 1; + level = RX_POWERSAVING_MAX_LEVEL; + preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + level_requested = true; + preamble_overridden = true; + } else if (strcmp(value, "overdrive") == 0) { + // One step past the guarded scale: measured lossless, outside the + // datasheet's timer condition. Same worst-case preamble as the presets above. + proposed.enabled = 1; + level = RX_POWERSAVING_OVERDRIVE_LEVEL; + preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + level_requested = true; + preamble_overridden = true; + } else if (strcmp(value, "riskyWorkingMax") == 0) { + // This one is not lossless, so the risk is explicit in its name. Same + // worst-case preamble as the presets above. + proposed.enabled = 1; + level = RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL; + preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + level_requested = true; + preamble_overridden = true; + } else if (strcmp(value, "balanced") == 0) { + proposed.enabled = 1; + level = RX_POWERSAVING_BALANCED_LEVEL; + preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + level_requested = true; + preamble_overridden = true; + } else { + char input[128]; + if (strlen(value) >= sizeof(input)) { + snprintf(reply, reply_size, + "ERROR: use off|on|conservative|balanced|max|overdrive|riskyWorkingMax|level <1-10>| "); + return false; + } + strcpy(input, value); + + const char* parts[4]; + int count = mesh::Utils::parseTextParts(input, parts, 4, ' '); + uint32_t first = 0; + uint32_t second = 0; + if (count == 1 && parseRxPowerSavingUint32(parts[0], &first)) { + if (first < 1 || first > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL) { + snprintf(reply, reply_size, "ERROR: level range is 1-%u (or max|overdrive|riskyWorkingMax); preamble is 16 or 32", + RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL); + return false; + } + level = (uint8_t)first; + level_requested = true; + proposed.enabled = 1; + } else if (count >= 2 && strcmp(parts[1], "preamble") == 0 && + (strcmp(parts[0], "max") == 0 || strcmp(parts[0], "overdrive") == 0 || + strcmp(parts[0], "riskyWorkingMax") == 0)) { + if (count != 3 || !parseRxPowerSavingUint32(parts[2], &first) || + (first != 16 && first != 32)) { + snprintf(reply, reply_size, "ERROR: use %s preamble <16|32>", parts[0]); + return false; + } + level = strcmp(parts[0], "overdrive") == 0 ? RX_POWERSAVING_OVERDRIVE_LEVEL + : strcmp(parts[0], "max") == 0 ? RX_POWERSAVING_MAX_LEVEL + : RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL; + preamble = (uint8_t)first; + level_requested = true; + preamble_overridden = true; + proposed.enabled = 1; + } else if (count == 2 && strcmp(parts[0], "level") == 0 && + parseRxPowerSavingUint32(parts[1], &first)) { + if (first < 1 || first > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL) { + snprintf(reply, reply_size, "ERROR: level range is 1-%u (or max|overdrive|riskyWorkingMax); preamble is 16 or 32", + RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL); + return false; + } + level = (uint8_t)first; + level_requested = true; + proposed.enabled = 1; + } else if (count == 4 && strcmp(parts[0], "level") == 0 && + parseRxPowerSavingUint32(parts[1], &first) && + strcmp(parts[2], "preamble") == 0 && + parseRxPowerSavingUint32(parts[3], &second)) { + if (first < 1 || first > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL || + (second != 16 && second != 32)) { + snprintf(reply, reply_size, "ERROR: level range is 1-%u (or max|overdrive|riskyWorkingMax); preamble is 16 or 32", + RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL); + return false; + } + level = (uint8_t)first; + preamble = (uint8_t)second; + level_requested = true; + preamble_overridden = true; + proposed.enabled = 1; + } else if (count == 2 && parseRxPowerSavingUint32(parts[0], &first) && + parseRxPowerSavingUint32(parts[1], &second)) { + proposed.rx_us = first; + proposed.sleep_us = second; + proposed.enabled = 1; + } else { + snprintf(reply, reply_size, + "ERROR: use off|on|conservative|balanced|max|overdrive|riskyWorkingMax|level <1-10>| "); + return false; + } + } + + if (proposed.enabled && (sf < 5 || sf > 12 || !isfinite(bw) || bw <= 0)) { + if (control) { + control->setRxPowerSaving(false, proposed.rx_us, proposed.sleep_us); + control->setRxPowerSavingConfigError(true); + } + snprintf(reply, reply_size, "ERROR: invalid SF/BW for RXPS"); + return false; + } + if (level_requested && + !recalcRxPowerSavingFromLevel(level, sf, bw, preamble, + &proposed.rx_us, &proposed.sleep_us, + rxPowerSavingCaptureCost(control), + rxPowerSavingTransition(control))) { + // Level and preamble were validated above, so the only way to get here is + // that no sleep exists which both arms the hardware and still lets a + // preamble of this length be caught - i.e. the symbol is too short for RXPS + // at this SF/BW. Saying "level range is 1-10" would send the user hunting + // for the wrong thing. + if (control) { + control->setRxPowerSaving(false, proposed.rx_us, proposed.sleep_us); + control->setRxPowerSavingConfigError(true); + } + snprintf(reply, reply_size, "ERROR: RXPS does not fit this SF/BW with preamble %lu", + (unsigned long)preamble); + return false; + } + if (!isValidRxPowerSavingPeriod(proposed.rx_us) || + !isValidRxPowerSavingPeriod(proposed.sleep_us)) { + snprintf(reply, reply_size, "ERROR: range is %lu-%lu us", + (unsigned long)RX_POWERSAVING_MIN_PERIOD_US, + (unsigned long)RX_POWERSAVING_MAX_PERIOD_US); + return false; + } + + bool applied = control != nullptr + ? control->setRxPowerSaving(proposed.enabled != 0, proposed.rx_us, proposed.sleep_us) + : proposed.enabled == 0; + if (!applied) { + snprintf(reply, reply_size, "ERROR: RX powersaving unsupported"); + return false; + } + + if (control) control->setRxPowerSavingConfigError(false); + + if (level_requested) { + proposed.level = level; + proposed.preamble = preamble_overridden ? preamble : 0; + } else if (strcmp(value, "off") != 0) { + proposed.level = 0; + proposed.preamble = 0; + } + + *config = proposed; + snprintf(reply, reply_size, "OK - %s,level=%lu%s,preamble=%lu,rx=%lu,sleep=%lu", + config->enabled ? "on" : "off", (unsigned long)config->level, + rxPowerSavingLevelTag(config->level), + (unsigned long)config->preamble, (unsigned long)config->rx_us, + (unsigned long)config->sleep_us); + return true; +} + +void RXPowerSavingCLI::get(const RxPowerSavingConfig* config, + const RxPowerSavingControl* control, + uint8_t sf, float bw, char* reply, size_t reply_size) { + if (config == nullptr || reply == nullptr || reply_size == 0) return; + + RxPowerSavingStatus status = control != nullptr + ? control->getRxPowerSavingStatus() + : RxPowerSavingStatus{}; + // Preamble symbols the node actually catches. Not simply the number the level + // asked for: the sleep floor can shorten the sleep further, and at SF6 with + // the 16-symbol profile that collapses the first few levels onto the same + // point. Reporting the requested value there would be a lie. + char margin[24]; + margin[0] = 0; + if (status.last_error == 0 && isValidRxPowerSavingPeriod(config->sleep_us) && + config->level >= 1 && config->level <= RX_POWERSAVING_GUARDED_LEVELS && + sf >= 5 && sf <= 12 && isfinite(bw) && bw > 0.0f) { + const uint8_t preamble = config->preamble ? config->preamble : rxPowerSavingPreambleForSF(sf); + const float symbol_us = (1000.0f * (float)(1UL << sf)) / bw; + const float effective = (float)preamble - (float)config->sleep_us / symbol_us; + // No %f on this platform's printf, and rounding the whole and fractional + // parts separately overflows: 4.999 came out as "4.10". + const float scaled = effective * 10.0f + (effective < 0.0f ? -0.5f : 0.5f); + if (isfinite(scaled) && scaled > (float)INT_MIN && scaled < (float)INT_MAX) { + const int tenths = (int)scaled; + snprintf(margin, sizeof(margin), ",catch=%d.%d", tenths / 10, + tenths < 0 ? -(tenths % 10) : tenths % 10); + } + } + + // Overdrive has to be visible here, otherwise it reads as an ordinary level + // and nobody remembers it runs outside the datasheet rule. + int len = snprintf(reply, reply_size, + "> desired=%s,effective=%s,supported=%s,err=%d,fail=%lu,level=%lu%s%s,preamble=%lu,rx=%lu,sleep=%lu", + config->enabled ? "on" : "off", status.armed ? "armed" : "continuous", + status.supported ? "yes" : "no", (int)status.last_error, + (unsigned long)status.arm_failures, (unsigned long)config->level, + rxPowerSavingLevelTag(config->level), margin, + (unsigned long)config->preamble, (unsigned long)config->rx_us, + (unsigned long)config->sleep_us); + + // Only report the periods the hardware really runs with when a driver had to + // clamp them - printing them always would not fit the 160 byte reply buffer. + if (len > 0 && (size_t)len < reply_size && status.armed && + (status.effective_rx_us != config->rx_us || + status.effective_sleep_us != config->sleep_us)) { + snprintf(reply + len, reply_size - (size_t)len, ",erx=%lu,eslp=%lu", + (unsigned long)status.effective_rx_us, + (unsigned long)status.effective_sleep_us); + } +} diff --git a/src/helpers/radiolib/RXPowerSavingCLI.h b/src/helpers/radiolib/RXPowerSavingCLI.h new file mode 100644 index 00000000000..62acfb95acf --- /dev/null +++ b/src/helpers/radiolib/RXPowerSavingCLI.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +#include "RXPowerSaving.h" + +class RXPowerSavingCLI { +public: + static bool set(const char* value, uint8_t sf, float bw, RxPowerSavingConfig* config, + RxPowerSavingControl* control, char* reply, size_t reply_size); + // sf/bw are needed to turn the stored periods back into symbols, which is + // what the level scale is expressed in. + static void get(const RxPowerSavingConfig* config, const RxPowerSavingControl* control, + uint8_t sf, float bw, char* reply, size_t reply_size); +}; diff --git a/test/test_rx_power_saving/test_rx_power_saving.cpp b/test/test_rx_power_saving/test_rx_power_saving.cpp new file mode 100644 index 00000000000..c17c875c445 --- /dev/null +++ b/test/test_rx_power_saving/test_rx_power_saving.cpp @@ -0,0 +1,894 @@ +#include + +#include +#include + +#include "helpers/radiolib/RXPowerSaving.h" +#include "helpers/radiolib/RXPowerSavingCLI.h" + +class FakeRxPowerSavingControl : public RxPowerSavingControl { +public: + bool accept = true; + bool set_called = false; + bool requested_enabled = false; + uint32_t requested_rx_us = 0; + uint32_t requested_sleep_us = 0; + RxPowerSavingStatus status; + + bool setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) override { + set_called = true; + requested_enabled = enabled; + requested_rx_us = rx_us; + requested_sleep_us = sleep_us; + return accept; + } + + RxPowerSavingStatus getRxPowerSavingStatus() const override { return status; } +}; + +TEST(RxPowerSaving, DefaultsKeepRepeaterDisabledWithBalancedIntent) { + const RxPowerSavingConfig config; + + EXPECT_EQ(config.enabled, 0); + EXPECT_EQ(config.level, RX_POWERSAVING_BALANCED_LEVEL); + EXPECT_EQ(config.preamble, RX_POWERSAVING_PROFILE_PREAMBLE); + EXPECT_EQ(config.rx_us, RX_POWERSAVING_DEFAULT_RX_US); + EXPECT_EQ(config.sleep_us, RX_POWERSAVING_DEFAULT_SLEEP_US); +} + +TEST(RxPowerSaving, BaseControlRejectsEnableAndAcceptsContinuousRx) { + RxPowerSavingControl control; + + EXPECT_FALSE(control.setRxPowerSaving(true, 12345, 23456)); + EXPECT_TRUE(control.setRxPowerSaving(false, 12345, 23456)); + + const RxPowerSavingStatus status = control.getRxPowerSavingStatus(); + EXPECT_FALSE(status.supported); + EXPECT_FALSE(status.armed); + EXPECT_EQ(status.arm_failures, 0U); + EXPECT_EQ(status.effective_rx_us, 0U); + EXPECT_EQ(status.effective_sleep_us, 0U); +} + +TEST(RxPowerSaving, ArmRetryStopsAfterThreeFailuresAndSuccessResetsIt) { + RxPowerSavingArmRetryState retry; + + EXPECT_TRUE(retry.canAttempt()); + for (uint8_t expected = 1; expected <= RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES; expected++) { + retry.recordFailure(); + EXPECT_EQ(retry.consecutiveFailures(), expected); + } + EXPECT_FALSE(retry.canAttempt()); + + // Saturate instead of wrapping if a caller records another failure. + retry.recordFailure(); + EXPECT_EQ(retry.consecutiveFailures(), RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES); + + retry.recordSuccess(); + EXPECT_EQ(retry.consecutiveFailures(), 0); + EXPECT_TRUE(retry.canAttempt()); +} + +TEST(RxPowerSaving, ClearingRetryStateGrantsThreeFreshAttempts) { + RxPowerSavingArmRetryState retry; + for (uint8_t i = 0; i < RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES; i++) { + retry.recordFailure(); + } + ASSERT_FALSE(retry.canAttempt()); + + retry.reset(); + EXPECT_TRUE(retry.canAttempt()); + EXPECT_EQ(retry.consecutiveFailures(), 0); +} + +TEST(RxPowerSaving, RejectsInvalidProfileInputs) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + + EXPECT_FALSE(calcRxPowerSavingLevel(0, 10, 250.0f, 16, &rx_us, &sleep_us)); + // 8 is the top of the guarded scale, 9 overdrive, 10 the practical maximum. + EXPECT_TRUE(calcRxPowerSavingLevel(8, 10, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_TRUE(calcRxPowerSavingLevel(9, 10, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_TRUE(calcRxPowerSavingLevel(10, 10, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_FALSE(calcRxPowerSavingLevel(11, 10, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_FALSE(calcRxPowerSavingLevel(5, 4, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_FALSE(calcRxPowerSavingLevel(5, 10, 0.0f, 16, &rx_us, &sleep_us)); + EXPECT_FALSE(calcRxPowerSavingLevel(5, 10, 250.0f, 24, &rx_us, &sleep_us)); +} + +TEST(RxPowerSaving, AcceptsPeriodBoundariesOnly) { + EXPECT_FALSE(isValidRxPowerSavingPeriod(RX_POWERSAVING_MIN_PERIOD_US - 1)); + EXPECT_TRUE(isValidRxPowerSavingPeriod(RX_POWERSAVING_MIN_PERIOD_US)); + EXPECT_TRUE(isValidRxPowerSavingPeriod(RX_POWERSAVING_MAX_PERIOD_US)); + EXPECT_FALSE(isValidRxPowerSavingPeriod(RX_POWERSAVING_MAX_PERIOD_US + 1)); +} + +TEST(RxPowerSaving, RepairsInvalidPersistedPeriodsIndependently) { + uint32_t rx_us = 0; + uint32_t sleep_us = 12345; + + ensureRxPowerSavingDefaults(&rx_us, &sleep_us); + EXPECT_EQ(rx_us, RX_POWERSAVING_DEFAULT_RX_US); + EXPECT_EQ(sleep_us, 12345U); + + rx_us = 23456; + sleep_us = RX_POWERSAVING_MAX_PERIOD_US + 1; + ensureRxPowerSavingDefaults(&rx_us, &sleep_us); + EXPECT_EQ(rx_us, 23456U); + EXPECT_EQ(sleep_us, RX_POWERSAVING_DEFAULT_SLEEP_US); +} + +TEST(RxPowerSaving, NormalizesPersistedConfigBeforeApplying) { + RxPowerSavingConfig config; + config.enabled = 7; + config.level = 255; + config.preamble = 24; + config.rx_us = 0; + config.sleep_us = 0; + + normalizeRxPowerSavingConfig(&config, 10, 250.0f); + + EXPECT_EQ(config.enabled, 1); + EXPECT_EQ(config.level, RX_POWERSAVING_BALANCED_LEVEL); + EXPECT_EQ(config.preamble, RX_POWERSAVING_PROFILE_PREAMBLE); + EXPECT_EQ(config.rx_us, 49329U); + EXPECT_EQ(config.sleep_us, 23757U); +} + +TEST(RxPowerSaving, NumericInputIsStrictDecimal) { + EXPECT_TRUE(isRxPowerSavingNumeric("0")); + EXPECT_TRUE(isRxPowerSavingNumeric("123456")); + EXPECT_FALSE(isRxPowerSavingNumeric(nullptr)); + EXPECT_FALSE(isRxPowerSavingNumeric("")); + EXPECT_FALSE(isRxPowerSavingNumeric("-1")); + EXPECT_FALSE(isRxPowerSavingNumeric("1.5")); + EXPECT_FALSE(isRxPowerSavingNumeric("12x")); + EXPECT_FALSE(isRxPowerSavingNumeric(" 12")); +} + +TEST(RxPowerSaving, CompanionProfileIsBalancedPreambleSixteen) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_BALANCED_LEVEL, 10, 250.0f, 16, + &rx_us, &sleep_us)); + EXPECT_EQ(rx_us, 49329U); + EXPECT_EQ(sleep_us, 23757U); +} + +TEST(RxPowerSaving, NamedProfilesSitWhereTheScaleSaysTheyDo) { + EXPECT_EQ(RX_POWERSAVING_CONSERVATIVE_LEVEL, 3); + EXPECT_EQ(rxPowerSavingLevelCatch(RX_POWERSAVING_CONSERVATIVE_LEVEL, 16), 13.0f); + EXPECT_EQ(rxPowerSavingLevelCatch(RX_POWERSAVING_CONSERVATIVE_LEVEL, 32), 16.0f); + EXPECT_EQ(RX_POWERSAVING_BALANCED_LEVEL, 6); + EXPECT_EQ(rxPowerSavingLevelCatch(RX_POWERSAVING_BALANCED_LEVEL, 16), 10.0f); + EXPECT_EQ(rxPowerSavingLevelCatch(RX_POWERSAVING_BALANCED_LEVEL, 32), 10.0f); + // The floor is shared by both profiles, and is what makes a level mean the + // same geometry on an SX126x and an LR11x0. + EXPECT_EQ(rxPowerSavingLevelCatch(RX_POWERSAVING_MAX_LEVEL, 16), + RX_POWERSAVING_MIN_CATCH_SYMBOLS); + EXPECT_EQ(rxPowerSavingLevelCatch(RX_POWERSAVING_MAX_LEVEL, 32), + RX_POWERSAVING_MIN_CATCH_SYMBOLS); + EXPECT_EQ(RX_POWERSAVING_PROFILE_PREAMBLE, 16); + + // Margins fall monotonically and end at zero; nothing outside the guarded + // scale reports one. + for (uint8_t lv = 2; lv <= RX_POWERSAVING_GUARDED_LEVELS; lv++) { + EXPECT_LT(rxPowerSavingLevelCatch(lv, 16), rxPowerSavingLevelCatch(lv - 1, 16)); + EXPECT_LT(rxPowerSavingLevelCatch(lv, 32), rxPowerSavingLevelCatch(lv - 1, 32)); + } + EXPECT_EQ(rxPowerSavingLevelCatch(RX_POWERSAVING_OVERDRIVE_LEVEL, 16), 0.0f); + EXPECT_EQ(rxPowerSavingLevelCatch(0, 16), 0.0f); +} + +TEST(RxPowerSaving, HigherLevelTradesListenTimeForSleepTime) { + uint32_t conservative_rx_us = 0; + uint32_t conservative_sleep_us = 0; + uint32_t aggressive_rx_us = 0; + uint32_t aggressive_sleep_us = 0; + + ASSERT_TRUE(calcRxPowerSavingLevel(1, 10, 250.0f, 16, + &conservative_rx_us, &conservative_sleep_us)); + ASSERT_TRUE(calcRxPowerSavingLevel(10, 10, 250.0f, 16, + &aggressive_rx_us, &aggressive_sleep_us)); + EXPECT_GT(conservative_rx_us, aggressive_rx_us); + EXPECT_LT(conservative_sleep_us, aggressive_sleep_us); +} + +TEST(RxPowerSaving, AutoPreambleTracksSpreadingFactor) { + EXPECT_EQ(rxPowerSavingPreambleForSF(7), 32); + EXPECT_EQ(rxPowerSavingPreambleForSF(8), 32); + EXPECT_EQ(rxPowerSavingPreambleForSF(9), 16); + EXPECT_EQ(rxPowerSavingPreambleForSF(12), 16); +} + +TEST(RxPowerSaving, LevelIntentRetunesAfterRadioChange) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + + ASSERT_TRUE(recalcRxPowerSavingFromLevel(5, 8, 62.5f, 16, &rx_us, &sleep_us)); + uint32_t old_rx_us = rx_us; + uint32_t old_sleep_us = sleep_us; + + ASSERT_TRUE(recalcRxPowerSavingFromLevel(5, 9, 62.5f, 16, &rx_us, &sleep_us)); + // Sleep is pure symbols, so it doubles with the symbol time. The listen + // window no longer does: the SetRxDutyCycle timer guard adds half of the + // sleep->RX transition, which is a fixed number of microseconds and does not + // scale with SF. So the window grows, but by less than a factor of two. + EXPECT_NEAR((double)sleep_us, (double)old_sleep_us * 2.0, 32.0); + EXPECT_GT(rx_us, old_rx_us); + EXPECT_LT((double)rx_us, (double)old_rx_us * 2.0); +} + +TEST(RxPowerSaving, AutomaticPreambleRetunesAcrossSfBoundary) { + uint32_t automatic_rx_us = 0; + uint32_t automatic_sleep_us = 0; + uint32_t explicit_rx_us = 0; + uint32_t explicit_sleep_us = 0; + + ASSERT_TRUE(recalcRxPowerSavingFromLevel(5, 8, 250.0f, 0, + &automatic_rx_us, &automatic_sleep_us)); + ASSERT_TRUE(calcRxPowerSavingLevel(5, 8, 250.0f, 32, + &explicit_rx_us, &explicit_sleep_us)); + EXPECT_EQ(automatic_rx_us, explicit_rx_us); + EXPECT_EQ(automatic_sleep_us, explicit_sleep_us); + + ASSERT_TRUE(recalcRxPowerSavingFromLevel(5, 9, 250.0f, 0, + &automatic_rx_us, &automatic_sleep_us)); + ASSERT_TRUE(calcRxPowerSavingLevel(5, 9, 250.0f, 16, + &explicit_rx_us, &explicit_sleep_us)); + EXPECT_EQ(automatic_rx_us, explicit_rx_us); + EXPECT_EQ(automatic_sleep_us, explicit_sleep_us); +} + +TEST(RxPowerSaving, ManualTimingsAreNotRetuned) { + uint32_t rx_us = 12345; + uint32_t sleep_us = 23456; + + EXPECT_FALSE(recalcRxPowerSavingFromLevel(0, 10, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_EQ(rx_us, 12345U); + EXPECT_EQ(sleep_us, 23456U); +} + +TEST(RxPowerSavingCLI, AppliesNamedAndManualProfiles) { + RxPowerSavingConfig config; + FakeRxPowerSavingControl control; + char reply[192]; + + ASSERT_TRUE(RXPowerSavingCLI::set("conservative", 10, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_EQ(config.level, 3); + EXPECT_EQ(config.preamble, 16); + + ASSERT_TRUE(RXPowerSavingCLI::set("balanced", 10, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_TRUE(control.set_called); + EXPECT_TRUE(control.requested_enabled); + EXPECT_EQ(config.enabled, 1); + EXPECT_EQ(config.level, RX_POWERSAVING_BALANCED_LEVEL); + EXPECT_EQ(config.level, 6); + EXPECT_EQ(config.preamble, RX_POWERSAVING_PROFILE_PREAMBLE); + EXPECT_EQ(config.rx_us, 49329U); + EXPECT_EQ(config.sleep_us, 23757U); + + control.set_called = false; + ASSERT_TRUE(RXPowerSavingCLI::set("12345 23456", 10, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_TRUE(control.set_called); + EXPECT_EQ(config.level, 0); + EXPECT_EQ(config.preamble, 0); + EXPECT_EQ(config.rx_us, 12345U); + EXPECT_EQ(config.sleep_us, 23456U); +} + +TEST(RxPowerSavingCLI, StoresAutomaticPreambleIntentForLevel) { + RxPowerSavingConfig config; + FakeRxPowerSavingControl control; + char reply[192]; + + ASSERT_TRUE(RXPowerSavingCLI::set("level 5", 8, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_EQ(config.level, 5); + EXPECT_EQ(config.preamble, 0); + + uint32_t expected_rx_us = 0; + uint32_t expected_sleep_us = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(5, 8, 250.0f, 32, + &expected_rx_us, &expected_sleep_us)); + EXPECT_EQ(config.rx_us, expected_rx_us); + EXPECT_EQ(config.sleep_us, expected_sleep_us); +} + +TEST(RxPowerSavingCLI, DoesNotPersistRejectedOrInvalidChanges) { + RxPowerSavingConfig config; + RxPowerSavingConfig original = config; + FakeRxPowerSavingControl control; + char reply[192]; + + EXPECT_FALSE(RXPowerSavingCLI::set("level 0", 10, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_EQ(config.enabled, original.enabled); + EXPECT_EQ(config.level, original.level); + EXPECT_STREQ(reply, "ERROR: level range is 1-10 (or max|overdrive|riskyWorkingMax); preamble is 16 or 32"); + + control.accept = false; + EXPECT_FALSE(RXPowerSavingCLI::set("balanced", 10, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_EQ(config.enabled, original.enabled); + EXPECT_EQ(config.level, original.level); + EXPECT_STREQ(reply, "ERROR: RX powersaving unsupported"); +} + +TEST(RxPowerSavingCLI, RejectsValuesBeforeNarrowingOrOverflow) { + RxPowerSavingConfig config; + const RxPowerSavingConfig original = config; + FakeRxPowerSavingControl control; + char reply[192]; + + const char* invalid_values[] = { + "261", // used to alias to level 5 after uint8_t truncation + "level 266", // used to alias to level 10 + "level 5 preamble 272", // used to alias to preamble 16 + "4294967296 1000", // one above UINT32_MAX + "999999999999999999999999999", // must not wrap during parsing + }; + + for (const char* value : invalid_values) { + control.set_called = false; + EXPECT_FALSE(RXPowerSavingCLI::set(value, 10, 250.0f, &config, &control, + reply, sizeof(reply))) << value; + EXPECT_FALSE(control.set_called) << value; + EXPECT_EQ(config.enabled, original.enabled) << value; + EXPECT_EQ(config.level, original.level) << value; + EXPECT_EQ(config.preamble, original.preamble) << value; + EXPECT_EQ(config.rx_us, original.rx_us) << value; + EXPECT_EQ(config.sleep_us, original.sleep_us) << value; + } +} + +TEST(RxPowerSavingCLI, DisablingKeepsTimingIntent) { + RxPowerSavingConfig config; + config.enabled = 1; + config.level = 7; + config.preamble = 32; + config.rx_us = 34567; + config.sleep_us = 45678; + FakeRxPowerSavingControl control; + char reply[192]; + + ASSERT_TRUE(RXPowerSavingCLI::set("off", 10, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_FALSE(control.requested_enabled); + EXPECT_EQ(config.enabled, 0); + EXPECT_EQ(config.level, 7); + EXPECT_EQ(config.preamble, 32); + EXPECT_EQ(config.rx_us, 34567U); + EXPECT_EQ(config.sleep_us, 45678U); +} + +TEST(RxPowerSavingCLI, FormatsDesiredAndEffectiveStateSeparately) { + RxPowerSavingConfig config; + config.enabled = 1; + FakeRxPowerSavingControl control; + control.status = {true, false, -706, 2, 0, 0}; + char reply[192]; + + RXPowerSavingCLI::get(&config, &control, 10, 250.0f, reply, sizeof(reply)); + + EXPECT_NE(std::strstr(reply, "desired=on,effective=continuous,supported=yes"), nullptr); + EXPECT_NE(std::strstr(reply, "err=-706,fail=2"), nullptr); + EXPECT_EQ(std::strstr(reply, "erx="), nullptr); // nothing armed, nothing to report +} + +TEST(RxPowerSavingCLI, ReportsClampedPeriodsOnlyWhenTheyDiffer) { + RxPowerSavingConfig config; + config.enabled = 1; + FakeRxPowerSavingControl control; + char reply[192]; + + // driver armed exactly what was asked for -> no extra fields + control.status = {true, true, 0, 0, config.rx_us, config.sleep_us}; + RXPowerSavingCLI::get(&config, &control, 10, 250.0f, reply, sizeof(reply)); + EXPECT_NE(std::strstr(reply, "effective=armed"), nullptr); + EXPECT_EQ(std::strstr(reply, "erx="), nullptr); + + // driver had to stretch the RX window -> surface the real values + control.status = {true, true, 0, 0, config.rx_us + 4000, config.sleep_us}; + RXPowerSavingCLI::get(&config, &control, 10, 250.0f, reply, sizeof(reply)); + EXPECT_NE(std::strstr(reply, "erx=69625,eslp=60000"), nullptr); +} + +TEST(RxPowerSavingCLI, GetDoesNotMutateStoredConfig) { + RxPowerSavingConfig config; + config.rx_us = 0; // invalid, e.g. from an older persisted prefs file + config.sleep_us = 999; + FakeRxPowerSavingControl control; + char reply[192]; + + RXPowerSavingCLI::get(&config, &control, 10, 250.0f, reply, sizeof(reply)); + + EXPECT_EQ(config.rx_us, 0U); + EXPECT_EQ(config.sleep_us, 999U); +} + + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} + +// Measured on SX1262 at SF6/BW62.5: the P16 profile's own sleep is below the +// driver's arming floor on levels 1-5 (and on levels 1-2 at SF7), where +// startReceiveDutyCycle answers -708 and the wrapper falls back to continuous +// RX while the config still reports power saving as on. +TEST(RxPowerSaving, ShortSleepIsRaisedToTheArmingFloor) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + + // SF6/BW62.5: level 1 of the P16 profile asks for 2 symbols = 2048 us. + ASSERT_TRUE(calcRxPowerSavingLevel(1, 6, 62.5f, 16, &rx_us, &sleep_us)); + EXPECT_EQ(sleep_us, RX_POWERSAVING_MIN_SLEEP_US); + // Raising it must not break capture: the budget is preamble - cost symbols. + EXPECT_LE(sleep_us, (uint32_t)((16.0f - RX_POWERSAVING_CAPTURE_COST_SYMBOLS) * 1024.0f)); + + // Only level 2 still lands under the floor. Before MC_TCXO_DELAY_US dropped + // the transition from 6000 to 2600 us the floor was 6250 us and swallowed + // levels 2-6 as well, so most of the 16-symbol ladder collapsed onto one + // point at SF6 - the node quietly slept longer, and caught fewer symbols, + // than the level it was set to promised. + for (uint8_t lv = 2; lv <= 2; lv++) { + uint32_t rx_n = 0; + uint32_t sleep_n = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(lv, 6, 62.5f, 16, &rx_n, &sleep_n)) << (int)lv; + EXPECT_EQ(sleep_n, RX_POWERSAVING_MIN_SLEEP_US) << (int)lv; + EXPECT_EQ(rx_n, rx_us) << (int)lv; + } + + // Level 4 sleeps 3.8 symbols, which now fits above the floor, so it gets its + // own point and saves more power than the collapsed ones. + uint32_t rx5_us = 0; + uint32_t sleep5_us = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(4, 6, 62.5f, 16, &rx5_us, &sleep5_us)); + EXPECT_GT(sleep5_us, RX_POWERSAVING_MIN_SLEEP_US); + EXPECT_LT((double)rx5_us / (rx5_us + sleep5_us), + (double)rx_us / (rx_us + sleep_us)); +} + +TEST(RxPowerSaving, RejectsProfilesWhoseFloorWouldBreakCapture) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + + // SF6/BW250: the symbol is 256 us, so the whole P16 capture budget is + // 10 symbols = 2560 us - under the arming floor. No level can duty cycle + // here, and the caller has to hear that rather than get a silent fallback. + // + // This used to be SF7/BW250, which the lower transition has since brought + // within reach: its budget is 5120 us against a floor that fell from 6250 to + // 2850, so the whole P16 ladder became armable there. The refusal path still + // needs a setting that is genuinely out of reach, so the example moved down + // one SF rather than away. + EXPECT_FALSE(calcRxPowerSavingLevel(1, 6, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_FALSE(calcRxPowerSavingLevel(RX_POWERSAVING_MAX_LEVEL, 6, 250.0f, 16, + &rx_us, &sleep_us)); + + // The P32 profile has 26 symbols of budget at the same setting and survives. + EXPECT_TRUE(calcRxPowerSavingLevel(1, 6, 250.0f, 32, &rx_us, &sleep_us)); + EXPECT_GE(sleep_us, RX_POWERSAVING_MIN_SLEEP_US); + + // And the setting that moved: SF7/BW250 with P16 now arms on every level. + EXPECT_TRUE(calcRxPowerSavingLevel(1, 7, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_MAX_LEVEL, 7, 250.0f, 16, + &rx_us, &sleep_us)); +} + +TEST(RxPowerSaving, TransitionTimeComesFromTheRadioLikeCaptureCost) { + class SlowTcxoControl : public RxPowerSavingControl { + public: + uint32_t rxPowerSavingTransitionUs() const override { return 11750; } + } slow; + + EXPECT_EQ(rxPowerSavingTransition(nullptr), RX_POWERSAVING_TRANSITION_US); + EXPECT_EQ(rxPowerSavingTransition(&slow), 11750u); + + // SF7 rather than SF6: a 12 ms floor does not fit inside the 10-symbol + // capture budget of a 1024 us symbol, which is the rejection case above. + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(1, 7, 62.5f, 16, &rx_us, &sleep_us, + RX_POWERSAVING_CAPTURE_COST_SYMBOLS, + rxPowerSavingTransition(&slow))); + // Floor is transition + margin, snapped to a whole 15.625 us tick. + EXPECT_GE(sleep_us, 12000u); + EXPECT_LT(sleep_us, 12020u); +} + +TEST(RxPowerSavingCLI, ReportsWhenNoLevelCanDutyCycleAtThisRadioSetting) { + RxPowerSavingConfig config; + FakeRxPowerSavingControl control; + char reply[160]; + + // SF6/BW250 with the P16 profile: nothing between the arming floor and the + // capture budget, so the level must be refused with a message that names the + // real reason instead of blaming the level range. + EXPECT_FALSE(RXPowerSavingCLI::set("level 5 preamble 16", 6, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_NE(strstr(reply, "SF/BW"), nullptr); + EXPECT_TRUE(control.set_called); + EXPECT_FALSE(control.requested_enabled); + EXPECT_EQ(config.enabled, 0); +} + +// SX1261/2 datasheet, SetRxDutyCycle: on preamble detection the radio restarts +// its timer with 2*rxPeriod + sleepPeriod and requires +// Tpreamble + Theader <= 2 * rxPeriod + sleepPeriod +// Measured on two SX1262 boards: when that holds nothing goes wrong, and when +// it is broken the chip usually gets away with it - except at isolated register +// values one tick wide (rxPeriod 320 and 640) where it loses 35-100% of the +// packets it has already latched. Satisfying the condition is the only defence +// that does not depend on knowing every bad tick. +TEST(RxPowerSaving, EveryLevelSatisfiesTheDutyCycleTimerCondition) { + const uint8_t sfs[] = {6, 7, 8, 10, 12}; + const float bws[] = {62.5f, 250.0f}; + const uint8_t preambles[] = {16, 32}; + + for (uint8_t sf : sfs) { + for (float bw : bws) { + for (uint8_t preamble : preambles) { + for (uint8_t level = 1; level <= RX_POWERSAVING_GUARDED_LEVELS; level++) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + if (!calcRxPowerSavingLevel(level, sf, bw, preamble, &rx_us, &sleep_us)) { + continue; // rejected outright; nothing is armed, nothing to check + } + const float symbol_us = (1000.0f * (float)(1UL << sf)) / bw; + const float sync = sf <= 6 ? RX_POWERSAVING_SYNC_SYMBOLS_LOW_SF + : RX_POWERSAVING_SYNC_SYMBOLS; + const float need = + ((float)preamble + sync + RX_POWERSAVING_HEADER_SYMBOLS) * symbol_us; + // Compare on the register values the radio really runs, not on the + // microseconds we asked for: the driver truncates to 15.625 us ticks. + const uint32_t rx_ticks = (rx_us * 8) / 125; + const uint32_t sleep_ticks = + ((sleep_us - RX_POWERSAVING_TRANSITION_US) * 8) / 125; + const float restarted = (2.0f * rx_ticks + sleep_ticks) * 15.625f; + EXPECT_GE(restarted, need) + << "SF" << (int)sf << " BW" << bw << " P" << (int)preamble + << " level " << (int)level; + // And the periods handed out must themselves be whole ticks, so the + // CLI reports what the hardware runs. + EXPECT_EQ((rx_ticks * 125 + 7) / 8, rx_us); + } + } + } + } +} + +// Level 11 is the measured maximum: the geometry the bench ran before the timer +// guard existed, kept because the guard costs 4 to 12 percentage points of duty +// cycle. 8 symbols of listening, sleep right at the capture budget. +TEST(RxPowerSaving, OverdriveIsTheMeasuredGeometryAtEverySf) { + const uint8_t sfs[] = {6, 7, 8, 10}; + for (uint8_t sf : sfs) { + for (uint8_t preamble : {16, 32}) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_OVERDRIVE_LEVEL, sf, 62.5f, + (uint8_t)preamble, &rx_us, &sleep_us)) + << "SF" << (int)sf << " P" << preamble; + const float symbol_us = (1000.0f * (float)(1UL << sf)) / 62.5f; + EXPECT_NEAR(rx_us / symbol_us, 8.0f /* rx_edge_symbols */, 0.02f); + EXPECT_NEAR(sleep_us / symbol_us, + preamble - RX_POWERSAVING_CAPTURE_COST_SYMBOLS, 0.02f); + // The whole point: 23.5% duty on P32, 44.4% on P16, at every SF. + const double duty = 100.0 * rx_us / (rx_us + sleep_us); + EXPECT_NEAR(duty, preamble == 32 ? 23.5 : 44.4, 0.2); + } + } +} + +TEST(RxPowerSaving, OverdriveDeliberatelyBreaksTheTimerCondition) { + // Stated as a test so nobody later "fixes" it into compliance by accident: + // level 11 exists precisely because it sits outside the datasheet rule, and + // it was measured lossless there on three boards across SF6, SF7 and SF8. + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_OVERDRIVE_LEVEL, 6, 62.5f, 32, + &rx_us, &sleep_us)); + const float symbol_us = (1000.0f * 64.0f) / 62.5f; + const float need = (32.0f + RX_POWERSAVING_SYNC_SYMBOLS_LOW_SF + + RX_POWERSAVING_HEADER_SYMBOLS) * symbol_us; + const uint32_t rx_ticks = (rx_us * 8) / 125; + const uint32_t sleep_ticks = ((sleep_us - RX_POWERSAVING_TRANSITION_US) * 8) / 125; + EXPECT_LT((2.0f * rx_ticks + sleep_ticks) * RX_POWERSAVING_TICK_US, need); + + // and the top of the guarded scale must still satisfy it + uint32_t g_rx = 0; + uint32_t g_sleep = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_MAX_LEVEL, 6, 62.5f, 32, &g_rx, &g_sleep)); + const uint32_t g_rx_ticks = (g_rx * 8) / 125; + const uint32_t g_sleep_ticks = ((g_sleep - RX_POWERSAVING_TRANSITION_US) * 8) / 125; + EXPECT_GE((2.0f * g_rx_ticks + g_sleep_ticks) * RX_POWERSAVING_TICK_US, need); + EXPECT_LT(rx_us, g_rx); // and it really does listen less +} + +TEST(RxPowerSaving, OverdriveStepsOffTheKnownBadRegisterTicks) { + // 8 symbols land on rxPeriod tick 320 when the symbol is 625 us. No standard + // bandwidth produces that, which is why production never hit it - but the + // unguarded path has no other protection, so the check has to work. + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_OVERDRIVE_LEVEL, 6, 102.4f, 32, + &rx_us, &sleep_us)); + EXPECT_EQ((rx_us * 8) / 125, 321u); // nudged one tick clear of 320 + EXPECT_EQ(rx_us, 5016u); + + // The guarded scale needs no such help and must be left alone. + uint32_t g_rx = 0; + uint32_t g_sleep = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(10, 6, 102.4f, 32, &g_rx, &g_sleep)); + EXPECT_NE((g_rx * 8) / 125, 320u); +} + +TEST(RxPowerSavingCLI, OverdriveAndMaxPresetsSelectTheRightLevels) { + RxPowerSavingConfig config; + FakeRxPowerSavingControl control; + char reply[160]; + + // `max` is the top of the guarded scale, and like every named preset it + // assumes a 16-symbol sender, so at SF<=8 it is deliberately less economical + // than `level 8`, which follows the SF onto the 32-symbol profile. + ASSERT_TRUE(RXPowerSavingCLI::set("max", 8, 62.5f, &config, &control, + reply, sizeof(reply))); + EXPECT_EQ(config.level, RX_POWERSAVING_MAX_LEVEL); + EXPECT_EQ(config.level, 8); + EXPECT_EQ(rxPowerSavingLevelCatch(config.level, 16), RX_POWERSAVING_MIN_CATCH_SYMBOLS); + EXPECT_EQ(config.preamble, 16); + EXPECT_EQ(strstr(reply, "overdrive"), nullptr); + const uint32_t max_rx = config.rx_us; + const uint32_t max_sleep = config.sleep_us; + + ASSERT_TRUE(RXPowerSavingCLI::set("level 8", 8, 62.5f, &config, &control, + reply, sizeof(reply))); + EXPECT_EQ(config.preamble, 0); + EXPECT_LT((double)config.rx_us / (config.rx_us + config.sleep_us), + (double)max_rx / (max_rx + max_sleep)); + + // `overdrive` is one step past it, on the same worst-case preamble. + ASSERT_TRUE(RXPowerSavingCLI::set("overdrive", 8, 62.5f, &config, &control, + reply, sizeof(reply))); + EXPECT_EQ(config.level, RX_POWERSAVING_OVERDRIVE_LEVEL); + EXPECT_EQ(config.preamble, 16); + EXPECT_NE(strstr(reply, "overdrive"), nullptr); + EXPECT_LT(config.rx_us, max_rx); // and really does listen less + + control.status.supported = true; + control.status.armed = true; + RXPowerSavingCLI::get(&config, &control, 10, 250.0f, reply, sizeof(reply)); + EXPECT_NE(strstr(reply, "(overdrive)"), nullptr); + + // Both names take an explicit preamble, which is how the 32-symbol profile + // is reached without giving up the preset. + ASSERT_TRUE(RXPowerSavingCLI::set("overdrive preamble 32", 8, 62.5f, &config, + &control, reply, sizeof(reply))); + EXPECT_EQ(config.level, RX_POWERSAVING_OVERDRIVE_LEVEL); + EXPECT_EQ(config.preamble, 32); + ASSERT_TRUE(RXPowerSavingCLI::set("max preamble 32", 8, 62.5f, &config, + &control, reply, sizeof(reply))); + EXPECT_EQ(config.level, RX_POWERSAVING_MAX_LEVEL); + EXPECT_EQ(config.preamble, 32); + + EXPECT_FALSE(RXPowerSavingCLI::set("overdrive preamble 24", 8, 62.5f, &config, + &control, reply, sizeof(reply))); + EXPECT_FALSE(RXPowerSavingCLI::set("max preamble", 8, 62.5f, &config, + &control, reply, sizeof(reply))); + EXPECT_FALSE(RXPowerSavingCLI::set("level 11", 8, 62.5f, &config, &control, + reply, sizeof(reply))); +} + +TEST(RxPowerSaving, RiskyWorkingMaxIsTheMeasuredEdgeAndCostsDelivery) { + // The bench walked the profile past level 10 until delivery came back: + // virtual 11.0 for P32, 10.25 for P16. Both put the sleep beyond the capture + // budget, which is the whole reason they lose packets - 196/200 and 197/200 + // at SF8 with an LR1110 witnessing every transmission. + const float symbol_us = (1000.0f * 256.0f) / 62.5f; // SF8 / BW62.5 + + uint32_t rx32 = 0; + uint32_t sleep32 = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL, 8, 62.5f, 32, + &rx32, &sleep32)); + EXPECT_NEAR(rx32 / symbol_us, 7.111f, 0.02f); + EXPECT_NEAR(sleep32 / symbol_us, 27.222f, 0.02f); + EXPECT_GT(sleep32 / symbol_us, 32.0f - RX_POWERSAVING_CAPTURE_COST_SYMBOLS); + + uint32_t rx16 = 0; + uint32_t sleep16 = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL, 8, 62.5f, 16, + &rx16, &sleep16)); + EXPECT_NEAR(rx16 / symbol_us, 7.889f, 0.02f); + EXPECT_NEAR(sleep16 / symbol_us, 10.222f, 0.02f); + EXPECT_GT(sleep16 / symbol_us, 16.0f - RX_POWERSAVING_CAPTURE_COST_SYMBOLS); + + // It must sleep more than overdrive, or it would have no reason to exist. + uint32_t o_rx = 0; + uint32_t o_sleep = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_OVERDRIVE_LEVEL, 8, 62.5f, 32, + &o_rx, &o_sleep)); + EXPECT_GT((double)sleep32 / (rx32 + sleep32), (double)o_sleep / (o_rx + o_sleep)); + + // Both levels past the guarded scale skip the timer guard. + EXPECT_TRUE(isRxPowerSavingUnguardedLevel(RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL)); + EXPECT_TRUE(isRxPowerSavingUnguardedLevel(RX_POWERSAVING_OVERDRIVE_LEVEL)); + EXPECT_FALSE(isRxPowerSavingUnguardedLevel(RX_POWERSAVING_MAX_LEVEL)); +} + +TEST(RxPowerSavingCLI, RiskyWorkingMaxUsesOneNameEverywhere) { + RxPowerSavingConfig config; + FakeRxPowerSavingControl control; + char reply[160]; + + ASSERT_TRUE(RXPowerSavingCLI::set("riskyWorkingMax", 8, 62.5f, &config, + &control, reply, sizeof(reply))); + EXPECT_EQ(config.level, RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL); + EXPECT_EQ(config.preamble, 16); + EXPECT_NE(strstr(reply, "(riskyWorkingMax)"), nullptr); + + ASSERT_TRUE(RXPowerSavingCLI::set("riskyWorkingMax preamble 32", 8, 62.5f, + &config, &control, reply, sizeof(reply))); + EXPECT_EQ(config.preamble, 32); + + control.status.supported = true; + control.status.armed = true; + RXPowerSavingCLI::get(&config, &control, 10, 250.0f, reply, sizeof(reply)); + EXPECT_NE(strstr(reply, "(riskyWorkingMax)"), nullptr); + + // The name remains case-sensitive and has no shortcut. + EXPECT_FALSE(RXPowerSavingCLI::set("riskyworkingmax", 8, 62.5f, &config, &control, + reply, sizeof(reply))); +} + +// The whole point of the scale: level N means "a sender's preamble may be N +// symbols shorter than this profile assumes and still be caught". That has to +// hold on every SF, bandwidth and profile, because being dimensionless is the +// reason the scale is expressed this way rather than in microseconds or in +// milliamperes - both of which differ per board. +TEST(RxPowerSaving, EveryGuardedLevelDeliversTheMarginItPromises) { + const uint8_t sfs[] = {7, 8, 9, 10, 12}; + const float bws[] = {62.5f, 125.0f, 250.0f}; + const uint8_t preambles[] = {16, 32}; + + for (uint8_t sf : sfs) { + for (float bw : bws) { + for (uint8_t preamble : preambles) { + for (uint8_t level = 1; level <= RX_POWERSAVING_GUARDED_LEVELS; level++) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + if (!calcRxPowerSavingLevel(level, sf, bw, preamble, &rx_us, &sleep_us)) { + continue; // rejected outright, nothing to check + } + if (sleep_us == RX_POWERSAVING_MIN_SLEEP_US) { + continue; // the floor ate the margin; reported by the CLI, not a bug + } + const float symbol_us = (1000.0f * (float)(1UL << sf)) / bw; + const float caught = (float)preamble - (float)sleep_us / symbol_us; + // The delivered catch is the ladder value plus the pad that keeps + // every generated geometry off the defective register pairs. + EXPECT_NEAR(caught, + rxPowerSavingLevelCatch(level, preamble) + RX_POWERSAVING_MARGIN_PAD_SYMBOLS, + 0.05f) + << "SF" << (int)sf << " BW" << bw << " P" << (int)preamble + << " level " << (int)level; + } + } + } + } +} + +TEST(RxPowerSaving, ALevelMeansTheSameGeometryOnBothRadioFamilies) { + // This is what the catch scale buys that the old margin scale could not: the + // capture cost is no longer part of the arithmetic, so a level produces the + // same periods whether the driver declares 6 symbols (SX126x) or 8 (LR11x0). + // Before, the same level number meant two different sleeps on the two chips. + for (uint8_t level = 1; level <= RX_POWERSAVING_GUARDED_LEVELS; level++) { + for (uint8_t preamble : {16, 32}) { + uint32_t rx_sx = 0, sleep_sx = 0, rx_lr = 0, sleep_lr = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(level, 8, 62.5f, preamble, &rx_sx, &sleep_sx, + RX_POWERSAVING_CAPTURE_COST_SYMBOLS)); + ASSERT_TRUE(calcRxPowerSavingLevel(level, 8, 62.5f, preamble, &rx_lr, &sleep_lr, + RX_POWERSAVING_CAPTURE_COST_SYMBOLS_LR11X0)); + EXPECT_EQ(sleep_sx, sleep_lr) << "level " << (int)level << " P" << (int)preamble; + EXPECT_EQ(rx_sx, rx_lr) << "level " << (int)level << " P" << (int)preamble; + } + } +} + +TEST(RxPowerSaving, FailedNormalizationCannotAcceptStalePeriods) { + RxPowerSavingConfig config; + config.enabled = 1; + const auto before = config; + EXPECT_FALSE(normalizeRxPowerSavingConfig(&config, 6, 250)); + EXPECT_EQ(before.rx_us, config.rx_us); + EXPECT_EQ(before.sleep_us, config.sleep_us); + EXPECT_EQ(1, config.enabled); +} +TEST(RxPowerSaving, RejectsNonFiniteAndExtremeBandwidthBeforeIntegerConversion) { + RxPowerSavingConfig config; + for (float bw : {0.0f, -1.0f, 1.0e-30f, std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN()}) { + EXPECT_FALSE(normalizeRxPowerSavingConfig(&config, 8, bw)); + } + EXPECT_FALSE(normalizeRxPowerSavingConfig(&config, 4, 62.5f)); + EXPECT_FALSE(normalizeRxPowerSavingConfig(&config, 13, 62.5f)); +} +TEST(RxPowerSaving, NormalizePreservesManualTimingsAcrossRadioChanges) { + RxPowerSavingConfig config; + config.enabled = 1; config.level = 0; config.rx_us = 12345; config.sleep_us = 23456; + for (uint8_t sf : {6, 8, 12}) { + ASSERT_TRUE(normalizeRxPowerSavingConfig(&config, sf, 250)); + EXPECT_EQ(12345U, config.rx_us); EXPECT_EQ(23456U, config.sleep_us); + } + config.rx_us = 0; + EXPECT_FALSE(normalizeRxPowerSavingConfig(&config, 8, 250)); + EXPECT_EQ(0U, config.rx_us); +} +TEST(RxPowerSaving, InvalidTimingFallsBackThenRecoversWithoutLosingIntent) { + RxPowerSavingConfig config; + FakeRxPowerSavingControl control; + config.enabled = 1; + ASSERT_TRUE(applyRxPowerSavingConfig(&config, 8, 62.5, &control)); + EXPECT_TRUE(control.requested_enabled); + ASSERT_FALSE(applyRxPowerSavingConfig(&config, 6, 250, &control)); + EXPECT_FALSE(control.requested_enabled); + EXPECT_EQ(1, config.enabled); EXPECT_EQ(6, config.level); + ASSERT_TRUE(applyRxPowerSavingConfig(&config, 8, 62.5, &control)); + EXPECT_TRUE(control.requested_enabled); + config.enabled = 0; + ASSERT_TRUE(applyRxPowerSavingConfig(&config, 6, 250, &control)); + EXPECT_FALSE(control.requested_enabled); +} + +TEST(RxPowerSaving, NarrowBandwidthIsNotRejectedByAnOverconservativeOverflowGuard) { + RxPowerSavingConfig config; + config.enabled = 1; + for (uint8_t level = 1; level <= 10; ++level) { + config.level = level; + ASSERT_TRUE(normalizeRxPowerSavingConfig(&config, 12, 7.8f)); + EXPECT_TRUE(isValidRxPowerSavingPeriod(config.rx_us)); + EXPECT_TRUE(isValidRxPowerSavingPeriod(config.sleep_us)); + } +} + +TEST(RxPowerSaving, ManualCLIRejectsInvalidRadioAndOffRemainsAvailable) { + RxPowerSavingConfig config;config.enabled=1; + FakeRxPowerSavingControl control;char reply[160]; + for(float bw:{0.0f, -1.0f, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) { + EXPECT_FALSE(RXPowerSavingCLI::set("12345 23456",8,bw,&config,&control,reply,sizeof(reply))); + EXPECT_FALSE(control.requested_enabled);EXPECT_EQ(1,config.enabled); + } + EXPECT_FALSE(RXPowerSavingCLI::set("12345 23456",4,125,&config,&control,reply,sizeof(reply))); + EXPECT_TRUE(RXPowerSavingCLI::set("off",4,0,&config,&control,reply,sizeof(reply))); + EXPECT_EQ(0,config.enabled); +} +TEST(RxPowerSaving, DiagnosticRetainsIntentStateAndErrorForExtremeInputs) { + RxPowerSavingConfig config;config.enabled=1;config.level=RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL; + config.rx_us=RX_POWERSAVING_MAX_PERIOD_US;config.sleep_us=RX_POWERSAVING_MAX_PERIOD_US; + FakeRxPowerSavingControl control;control.status.last_error=-10001;control.status.arm_failures=UINT32_MAX; + char reply[160]; + for(float bw:{std::numeric_limits::infinity(),std::numeric_limits::quiet_NaN(),1.0e30f,62.5f}) { + RXPowerSavingCLI::get(&config,&control,8,bw,reply,sizeof(reply)); + EXPECT_NE(nullptr,strstr(reply,"desired=on"));EXPECT_NE(nullptr,strstr(reply,"effective=continuous")); + EXPECT_NE(nullptr,strstr(reply,"err=-10001"));EXPECT_NE(nullptr,strstr(reply,"fail=4294967295")); + } + config.level=6; + RXPowerSavingCLI::get(&config,&control,8,1.0e30f,reply,sizeof(reply)); + EXPECT_EQ(nullptr,strstr(reply,"catch=")); +} + +TEST(RxPowerSaving, RejectsImpossibleCaptureCostBeforeConvertingPeriods) { + for(float cost:{16.0f,32.0f,1.0e30f,std::numeric_limits::infinity(),std::numeric_limits::quiet_NaN()}) { + for(uint8_t level=1;level<=10;level++) { + uint32_t rx=12345,sleep=23456; + EXPECT_FALSE(calcRxPowerSavingLevel(level,8,62.5f,16,&rx,&sleep,cost,2600)); + EXPECT_EQ(12345u,rx);EXPECT_EQ(23456u,sleep); + } + } +} From 8e1c7159dccf7b7697c4e8cd33f7fd6f78a32557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Fri, 16 Oct 2026 01:48:04 +0200 Subject: [PATCH 32/44] rxps: SX1262 RX duty cycle in the RadioLib wrapper RadioLibWrapper gains the RXPS state machine. When enabled, RX is started through the driver's armDutyCycle() instead of continuous startReceive(). Requested and effective periods are kept apart. TX, CAD, idle, power off and any radio reconfiguration (via prepareForRadioConfig()) stop the duty cycle first; recvRaw() reads a packet only once RX_DONE is set. A failed arm stops the hardware duty cycle and falls back to continuous RX, recording the error. CustomSX1262 implements the duty cycle: - TCXO start-up delay of 1600 us, as in v17; - BUSY is checked so the sleeping radio is not hit by SPI; - RXEN returns to RX; - the duty cycle is stopped through standby. LR2021 keeps its idle() before setOutputPower() in setTxPower(), which the PA/TxParams standby-only commands need (upstream 114093ee). Host tests in test/host (CMake, needs a RadioLib source tree): wrapper_lifecycle covers TX, CAD, arm failure and on/off, and wrapper_lifecycle_lr2021 covers the LR2021 set-tx path. From PowerSaving-v17 285cf10a. --- src/helpers/radiolib/CustomSX1262.h | 66 ++++++- src/helpers/radiolib/CustomSX1262Wrapper.h | 44 ++++- src/helpers/radiolib/RadioLibWrappers.cpp | 190 ++++++++++++++++++--- src/helpers/radiolib/RadioLibWrappers.h | 69 +++++++- test/host/CMakeLists.txt | 27 +++ test/host/platform_stub.h | 20 +++ test/host/wrapper_lifecycle.cpp | 75 ++++++++ 7 files changed, 461 insertions(+), 30 deletions(-) create mode 100644 test/host/CMakeLists.txt create mode 100644 test/host/platform_stub.h create mode 100644 test/host/wrapper_lifecycle.cpp diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index b4ee6c97aa2..45d62c01414 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -2,6 +2,7 @@ #include #include "MeshCore.h" +#include "RXPowerSaving.h" // MC_TCXO_DELAY_US class CustomSX1262 : public SX1262 { uint32_t _preambleMillis = 66; @@ -10,8 +11,40 @@ class CustomSX1262 : public SX1262 { bool _headerSeen = false; public: + // The sleep -> RX transition RadioLib subtracts from a duty-cycle sleep. + // Protected in the RadioLib base, so the RXPS layer reads it through here. + uint32_t getTcxoDelay() const { return tcxoDelay; } + CustomSX1262(Module *mod) : SX1262(mod) { } + // RadioLib's begin() installs its own 5000 us TCXO startup delay and + // setTCXO() does not recalibrate, so MeshCore's value has to be written + // afterwards and the RC calibrations redone against the new clock-gating + // window. Overriding begin() puts that in one place: every variant reaches + // the radio through this class, whether it calls std_init() or begin() + // directly, so no target.cpp or variant .ini has to know about it. + int16_t begin(float freq = 434.0, float bw = 125.0, uint8_t sf = 9, uint8_t cr = 7, + uint8_t syncWord = RADIOLIB_SX126X_SYNC_WORD_PRIVATE, int8_t power = 10, + uint16_t preambleLength = 8, float tcxoVoltage = 1.6, + bool useRegulatorLDO = false) { + int16_t state = SX1262::begin(freq, bw, sf, cr, syncWord, power, preambleLength, + tcxoVoltage, useRegulatorLDO); + if (state == RADIOLIB_ERR_NONE) state = applyMeshCoreTcxoDelay(); + return state; + } + + // Read tcxoVoltage back from the chip object rather than from the argument: + // begin() zeroes it when it falls back to an XTAL, and re-asserting a TCXO + // supply on DIO3 for a board that has none would be worse than a long delay. + int16_t applyMeshCoreTcxoDelay() { + if (tcxoVoltage <= 0.0f) return RADIOLIB_ERR_NONE; + int16_t state = setTCXO(tcxoVoltage, MC_TCXO_DELAY_US); + RADIOLIB_ASSERT(state); + state = calibrate(RADIOLIB_SX126X_CALIBRATE_ALL); + delay(50); + return state; + } + #ifdef RP2040_PLATFORM bool std_init(SPIClassRP2040* spi = NULL) #else @@ -105,6 +138,24 @@ class CustomSX1262 : public SX1262 { return SX1262::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); } + int16_t startReceiveDutyCycle(uint32_t rxPeriod, uint32_t sleepPeriod, + RadioLibIrqFlags_t irqFlags = RADIOLIB_IRQ_RX_DEFAULT_FLAGS, + RadioLibIrqFlags_t irqMask = RADIOLIB_IRQ_RX_DEFAULT_MASK) { + int16_t state = SX1262::startReceiveDutyCycle(rxPeriod, sleepPeriod, irqFlags, irqMask); + if (state == RADIOLIB_ERR_NONE) { + // RadioLib stages RX through standby, which leaves a host-controlled + // RXEN switch in IDLE. Set it back to RX for the whole duty-cycle; + // boards without an external RXEN table are unaffected. + this->mod->setRfSwitchState(Module::MODE_RX); + } + return state; + } + + bool isChipBusy() { + uint32_t busy = this->mod->getGpio(); + return busy != RADIOLIB_NC && this->mod->hal->digitalRead(busy); + } + bool isReceiving() { uint32_t irq = getIrqFlags(); bool preamble = irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; // bit 2 @@ -148,6 +199,19 @@ class CustomSX1262 : public SX1262 { return false; } + int16_t stopRTC() { + uint8_t rtcStop = 0x00; + int16_t state = writeRegister(RADIOLIB_SX126X_REG_RTC_CTRL, &rtcStop, 1); + RADIOLIB_ASSERT(state); + + uint8_t rtcEvent = 0; + state = readRegister(RADIOLIB_SX126X_REG_EVENT_MASK, &rtcEvent, 1); + RADIOLIB_ASSERT(state); + + rtcEvent |= 0x02; + return writeRegister(RADIOLIB_SX126X_REG_EVENT_MASK, &rtcEvent, 1); + } + void setPreambleMillis(uint32_t preambleMillis) { _preambleMillis = preambleMillis; MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); @@ -162,4 +226,4 @@ class CustomSX1262 : public SX1262 { readRegister(RADIOLIB_SX126X_REG_RX_GAIN, &rxGain, 1); return (rxGain == RADIOLIB_SX126X_RX_GAIN_BOOSTED); } -}; \ No newline at end of file +}; diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index be3144716bf..84fe32cf370 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -10,9 +10,19 @@ class CustomSX1262Wrapper : public RadioLibWrapper { public: + // Ask the radio rather than assume RadioLib's 5000 us default: startReceive- + // DutyCycle subtracts tcxoDelay + 1000 from the sleep and the register + // underflows below that, so the floor is a property of this board's TCXO. A + // board without one can duty cycle with a far shorter sleep. The 100 us on + // top clears the 15.625 us register tick that must remain. + uint32_t rxPowerSavingTransitionUs() const override { + return ((CustomSX1262 *)_radio)->getTcxoDelay() + 1000; + } + CustomSX1262Wrapper(CustomSX1262& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + prepareForRadioConfig(); ((CustomSX1262 *)_radio)->setFrequency(freq); ((CustomSX1262 *)_radio)->setSpreadingFactor(sf); ((CustomSX1262 *)_radio)->setBandwidth(bw); @@ -23,14 +33,20 @@ class CustomSX1262Wrapper : public RadioLibWrapper { ((CustomSX1262 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); } - bool isReceivingPacket() override { + bool isReceivingPacket() override { + // While duty-cycling, BUSY marks the sleep phase: probing IRQ flags over + // SPI there could wake the chip and break the cycle. Only skip the probe + // in that case - outside RXPS this must stay a real channel check, since + // Dispatcher::checkSend() relies on it to avoid transmitting over others. + if (_rx_ps_armed && ((CustomSX1262 *)_radio)->isChipBusy()) return false; return ((CustomSX1262 *)_radio)->isReceiving(); } + bool isChipBusy() override { + return ((CustomSX1262 *)_radio)->isChipBusy(); + } float getCurrentRSSI() override { return ((CustomSX1262 *)_radio)->getRSSI(false); } - float getLastRSSI() const override { return ((CustomSX1262 *)_radio)->getRSSI(); } - float getLastSNR() const override { return ((CustomSX1262 *)_radio)->getSNR(); } float packetScore(float snr, int packet_len) override { int sf = ((CustomSX1262 *)_radio)->spreadingFactor; @@ -38,10 +54,32 @@ class CustomSX1262Wrapper : public RadioLibWrapper { } uint8_t getSpreadingFactor() const override { return ((CustomSX1262 *)_radio)->spreadingFactor; } virtual void powerOff() override { + if (_rx_ps_armed) stopReceiveDutyCycle(); ((CustomSX1262 *)_radio)->sleep(false); } + bool supportsRxPowerSaving() const override { return true; } + +protected: + int16_t armDutyCycle(RadioLibIrqFlags_t irq_flags, RadioLibIrqFlags_t irq_mask, + uint32_t* eff_rx_us, uint32_t* eff_sleep_us) override { + // RadioLib programs the SX126x with exactly what we ask for (it only + // subtracts the wake-up transition from the sleep period internally). + *eff_rx_us = _rx_ps_rx_us; + *eff_sleep_us = _rx_ps_sleep_us; + return ((CustomSX1262 *)_radio)->startReceiveDutyCycle( + _rx_ps_rx_us, _rx_ps_sleep_us, irq_flags, irq_mask); + } + + int16_t stopDutyCycleHardware() override { + int16_t standby_err = _radio->standby(); + int16_t rtc_err = ((CustomSX1262 *)_radio)->stopRTC(); + return standby_err != RADIOLIB_ERR_NONE ? standby_err : rtc_err; + } + +public: bool setRxBoostedGainMode(bool en) override { + prepareForRadioConfig(); return ((CustomSX1262 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } bool getRxBoostedGainMode() const override { diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index e4d2ba1c272..eb01ffd2d71 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -11,6 +11,12 @@ #define NUM_NOISE_FLOOR_SAMPLES 64 #define SAMPLING_THRESHOLD 14 +#define NF_CALIB_INTERVAL_MS 60000UL +#define NF_CALIB_TIMEOUT_MS 5000UL +#define NF_CALIB_SETTLE_MS 20UL +#define NF_CALIB_SAMPLE_INTERVAL_MS 1UL +#define NF_CALIB_MAX_SAMPLE_ATTEMPTS (NUM_NOISE_FLOOR_SAMPLES * 4U) + static volatile uint8_t state = STATE_IDLE; // this function is called when a complete packet @@ -48,17 +54,32 @@ uint32_t RadioLibWrapper::getRngSeed() { } void RadioLibWrapper::setTxPower(int8_t dbm) { + prepareForRadioConfig(); #if defined(USE_LR2021) + // On LR2021, setOutputPower() writes the PA config and TxParams, which are + // standby-only commands. recvRaw() keeps state == STATE_RX after readData on + // this platform ("LR2021 stays in Rx"), so - unlike the SX126x path, which + // falls back to STATE_IDLE and re-arms on the next recvRaw() - nothing calls + // startReceive() again by itself. Writing the PA config while listening could + // therefore leave the receiver down until the next reboot. Drop to standby + // first and let checkRecv() re-arm Rx, as resetAGC() and + // applySideDetectorConfig() already do. idle(); #endif _radio->setOutputPower(dbm); } void RadioLibWrapper::idle() { + if (_rx_ps_armed) stopReceiveDutyCycle(); _radio->standby(); state = STATE_IDLE; // need another startReceive() } +void RadioLibWrapper::powerOff() { + if (_rx_ps_armed) stopReceiveDutyCycle(); + _radio->sleep(); +} + void RadioLibWrapper::triggerNoiseFloorCalibrate(int threshold) { _threshold = threshold; if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES) { // ignore trigger if currently sampling @@ -75,6 +96,7 @@ void RadioLibWrapper::resetAGC() { // make sure we're not mid-receive of packet! if ((state & STATE_INT_READY) != 0 || isReceivingPacket()) return; + if (_rx_ps_armed) stopReceiveDutyCycle(); doResetAGC(); state = STATE_IDLE; // trigger a startReceive() @@ -87,9 +109,33 @@ void RadioLibWrapper::resetAGC() { _floor_sample_sum = 0; } + + + + +// Clear the RX/idle state so the next loop calls startRecv() again, without +// dropping an STATE_INT_READY that the ISR may raise while we are in here. +// A plain `state = STATE_IDLE` loses that flag (and with it, a received +// packet) when setFlag() fires between the test and the store. +void RadioLibWrapper::requestRestartRecv() { + noInterrupts(); + if ((state & ~STATE_INT_READY) != STATE_TX_WAIT) { + state &= STATE_INT_READY; // STATE_IDLE, but keep a pending interrupt + } + interrupts(); +} + +bool RadioLibWrapper::isPacketPendingOrReceiving() { + return (state & STATE_INT_READY) != 0 || isReceivingPacket(); +} + + + + + void RadioLibWrapper::loop() { if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { - if (!isReceivingPacket()) { + if (!_rx_ps_armed && !isReceivingPacket()) { int rssi = getCurrentRSSI(); if (rssi < _noise_floor + SAMPLING_THRESHOLD) { // only consider samples below current floor + sampling THRESHOLD _num_floor_samples++; @@ -113,7 +159,7 @@ void RadioLibWrapper::startRecv() { #if defined(USE_LR2021) _radio->standby(); // without this LR2021 can throw -706 when calling startReceive after hardware CAD when side detectors are enabled #endif - int err = _radio->startReceive(); + int err = startReceiveMode(); if (err == RADIOLIB_ERR_NONE) { state = STATE_RX; } else { @@ -121,6 +167,100 @@ void RadioLibWrapper::startRecv() { } } +int16_t RadioLibWrapper::startReceiveMode() { + // Periodic calibration switches RXPS to continuous RX. Re-check at the + // hardware transition so a preamble that arrived after the scheduler's + // idle check is not aborted by stopReceiveDutyCycle(). + if (_nf_calib_active && _rx_ps_armed && isPacketPendingOrReceiving()) { + return RADIOLIB_ERR_NONE; + } + if (_rx_ps_armed) stopReceiveDutyCycle(); + if (!_rx_ps_enabled || _nf_calib_active) { + _rx_ps_armed = false; + return _radio->startReceive(); + } + + const RadioLibIrqFlags_t irq_flags = + RADIOLIB_IRQ_RX_DEFAULT_FLAGS | + (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED); + const RadioLibIrqFlags_t irq_mask = + (1UL << RADIOLIB_IRQ_RX_DONE) | + (1UL << RADIOLIB_IRQ_TIMEOUT) | + (1UL << RADIOLIB_IRQ_CRC_ERR) | + (1UL << RADIOLIB_IRQ_HEADER_ERR); + + uint32_t eff_rx_us = _rx_ps_rx_us; + uint32_t eff_sleep_us = _rx_ps_sleep_us; + int16_t err = armDutyCycle(irq_flags, irq_mask, &eff_rx_us, &eff_sleep_us); + if (err == RADIOLIB_ERR_NONE) { + _rx_ps_armed = true; + _rx_ps_last_error = RADIOLIB_ERR_NONE; + _rx_ps_eff_rx_us = eff_rx_us; + _rx_ps_eff_sleep_us = eff_sleep_us; + return err; + } + + _rx_ps_armed = false; + _rx_ps_last_error = err; + _rx_ps_eff_rx_us = 0; + _rx_ps_eff_sleep_us = 0; + n_rx_ps_arm_failures++; + MESH_DEBUG_PRINTLN("RadioLibWrapper: startReceiveDutyCycle(%d), continuous RX fallback", err); + stopDutyCycleHardware(); + int16_t fallback_err = _radio->startReceive(); + if (fallback_err != RADIOLIB_ERR_NONE) _rx_ps_last_error = fallback_err; + return fallback_err; +} + +void RadioLibWrapper::stopReceiveDutyCycle() { + int16_t err = stopDutyCycleHardware(); + _rx_ps_armed = false; + _rx_ps_eff_rx_us = 0; + _rx_ps_eff_sleep_us = 0; + if (err != RADIOLIB_ERR_NONE) _rx_ps_last_error = err; +} + +bool RadioLibWrapper::isPacketReady() { + if (!_rx_ps_armed) return true; + return _radio->checkIrq(RADIOLIB_IRQ_RX_DONE) != 0; +} + +void RadioLibWrapper::prepareForRadioConfig() { + if (!_rx_ps_armed) return; + + stopReceiveDutyCycle(); + requestRestartRecv(); +} + +bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) { + if (enabled && (!isValidRxPowerSavingPeriod(rx_us) || !isValidRxPowerSavingPeriod(sleep_us))) { + _rx_ps_enabled = false; + _rx_ps_last_error = RX_POWERSAVING_ERR_TIMING; + requestRestartRecv(); + return false; + } + if (enabled && !supportsRxPowerSaving()) return false; + + _rx_ps_enabled = enabled; + _rx_ps_rx_us = rx_us; + _rx_ps_sleep_us = sleep_us; + _rx_ps_last_error = RADIOLIB_ERR_NONE; + _rx_ps_arm_retry.reset(); // new config, give the hardware a fresh chance + requestRestartRecv(); + return true; +} + +RxPowerSavingStatus RadioLibWrapper::getRxPowerSavingStatus() const { + RxPowerSavingStatus status; + status.supported = supportsRxPowerSaving(); + status.armed = _rx_ps_armed; + status.last_error = _rx_ps_config_error ? RX_POWERSAVING_ERR_TIMING : _rx_ps_last_error; + status.arm_failures = n_rx_ps_arm_failures; + status.effective_rx_us = _rx_ps_eff_rx_us; + status.effective_sleep_us = _rx_ps_eff_sleep_us; + return status; +} + bool RadioLibWrapper::isInRecvMode() const { return (state & ~STATE_INT_READY) == STATE_RX; } @@ -128,17 +268,23 @@ bool RadioLibWrapper::isInRecvMode() const { int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { int len = 0; if (state & STATE_INT_READY) { - len = _radio->getPacketLength(); - if (len > 0) { - if (len > sz) { len = sz; } - int err = _radio->readData(bytes, len); - if (err != RADIOLIB_ERR_NONE) { - MESH_DEBUG_PRINTLN("RadioLibWrapper: error: readData(%d)", err); - len = 0; - n_recv_errors++; - } else { - // Serial.print(" readData() -> "); Serial.println(len); - n_recv++; + if (isPacketReady()) { + if (_rx_ps_armed) stopReceiveDutyCycle(); + len = _radio->getPacketLength(); + if (len > 0) { + if (len > sz) { len = sz; } + _last_snr = _radio->getSNR(); + _last_rssi = _radio->getRSSI(); + _last_metrics_valid = true; + int err = _radio->readData(bytes, len); + if (err != RADIOLIB_ERR_NONE) { + MESH_DEBUG_PRINTLN("RadioLibWrapper: error: readData(%d)", err); + len = 0; + n_recv_errors++; + } else { + // Serial.print(" readData() -> "); Serial.println(len); + n_recv++; + } } } #if defined(USE_LR2021) @@ -149,12 +295,7 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { } if (state != STATE_RX) { - int err = _radio->startReceive(); - if (err == RADIOLIB_ERR_NONE) { - state = STATE_RX; - } else { - MESH_DEBUG_PRINTLN("RadioLibWrapper: error: startReceive(%d)", err); - } + startRecv(); } return len; } @@ -164,6 +305,7 @@ uint32_t RadioLibWrapper::getEstAirtimeFor(int len_bytes) { } bool RadioLibWrapper::startSendRaw(const uint8_t* bytes, int len) { + if (_rx_ps_armed) stopReceiveDutyCycle(); _board->onBeforeTransmit(); int err = _radio->startTransmit((uint8_t *) bytes, len); if (err == RADIOLIB_ERR_NONE) { @@ -197,10 +339,12 @@ int16_t RadioLibWrapper::performChannelScan() { bool RadioLibWrapper::isChannelActive() { // int.thresh: RSSI-based interference detection (relative to noise floor) - if (_threshold != 0 && getCurrentRSSI() > _noise_floor + _threshold) return true; + if (_threshold != 0 && !(_rx_ps_armed && isChipBusy()) && + getCurrentRSSI() > _noise_floor + _threshold) return true; // cad: hardware channel activity detection if (_cad_enabled) { + if (_rx_ps_armed) stopReceiveDutyCycle(); int16_t result = performChannelScan(); // scanChannel() triggers DIO interrupt (CAD done) which sets STATE_INT_READY // via setFlag() ISR. Clear it before restarting RX so recvRaw() doesn't @@ -214,10 +358,12 @@ bool RadioLibWrapper::isChannelActive() { } float RadioLibWrapper::getLastRSSI() const { - return _radio->getRSSI(); + if (_last_metrics_valid) return _last_rssi; + return _rx_ps_armed ? 0 : _radio->getRSSI(); } float RadioLibWrapper::getLastSNR() const { - return _radio->getSNR(); + if (_last_metrics_valid) return _last_snr; + return _rx_ps_armed ? 0 : _radio->getSNR(); } // Approximate SNR threshold per SF for successful reception (based on Semtech datasheets) diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 77dd93116b5..05a5d836001 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -2,6 +2,7 @@ #include #include +#include "RXPowerSaving.h" #ifdef USE_CC310_HW_CRYPTO #include @@ -11,28 +12,78 @@ struct PacketMillis { uint32_t payloadMillis; // header-valid -> rx-done deadline }; -class RadioLibWrapper : public mesh::Radio { +class RadioLibWrapper : public mesh::Radio, public RxPowerSavingControl { protected: PhysicalLayer* _radio; mesh::MainBoard* _board; uint32_t n_recv, n_sent, n_recv_errors; int16_t _noise_floor, _threshold; + float _last_rssi, _last_snr; + bool _last_metrics_valid; bool _cad_enabled; uint16_t _num_floor_samples; int32_t _floor_sample_sum; uint8_t _preamble_sf; + bool _rx_ps_config_error = false; + bool _rx_ps_enabled; + bool _rx_ps_armed; + uint32_t _rx_ps_rx_us; + uint32_t _rx_ps_sleep_us; + uint32_t _rx_ps_eff_rx_us; // periods the driver actually armed with + uint32_t _rx_ps_eff_sleep_us; + int16_t _rx_ps_last_error; + uint32_t n_rx_ps_arm_failures; + RxPowerSavingArmRetryState _rx_ps_arm_retry; + + bool _nf_calib_active; + unsigned long _nf_last_calib; + unsigned long _nf_calib_deadline; + unsigned long _nf_sample_from; + void idle(); void startRecv(); + void requestRestartRecv(); + bool isPacketPendingOrReceiving(); + void prepareForRadioConfig(); + void sampleNoiseFloorOnce(); + bool publishNoiseFloor(); + void noiseFloorCalibCheck(); + void endNoiseFloorCalib(unsigned long now); + int16_t startReceiveMode(); + void stopReceiveDutyCycle(); + // eff_rx_us/eff_sleep_us report the periods the driver really programmed, + // which may be clamped to satisfy a hardware constraint. + virtual int16_t armDutyCycle(RadioLibIrqFlags_t irq_flags, + RadioLibIrqFlags_t irq_mask, + uint32_t* eff_rx_us, uint32_t* eff_sleep_us) { + (void)irq_flags; + (void)irq_mask; + (void)eff_rx_us; + (void)eff_sleep_us; + return RADIOLIB_ERR_UNSUPPORTED; + } + virtual int16_t stopDutyCycleHardware() { return _radio->standby(); } + virtual bool isPacketReady(); + virtual bool isChipBusy() { return false; } float packetScoreInt(float snr, int sf, int packet_len); virtual bool isReceivingPacket() =0; virtual void doResetAGC(); public: - RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _preamble_sf(0) { n_recv = n_sent = 0; } + RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) + : _radio(&radio), _board(&board), _last_rssi(0), _last_snr(0), _last_metrics_valid(false), + _preamble_sf(0), _rx_ps_enabled(false), _rx_ps_armed(false), + _rx_ps_rx_us(RX_POWERSAVING_DEFAULT_RX_US), + _rx_ps_sleep_us(RX_POWERSAVING_DEFAULT_SLEEP_US), _rx_ps_eff_rx_us(0), + _rx_ps_eff_sleep_us(0), _rx_ps_last_error(RADIOLIB_ERR_NONE), + n_rx_ps_arm_failures(0), _nf_calib_active(false), + _nf_last_calib(0), _nf_calib_deadline(0), _nf_sample_from(0) { + n_recv = n_sent = n_recv_errors = 0; + } void begin() override; - virtual void powerOff() { _radio->sleep(); } + virtual void powerOff(); int recvRaw(uint8_t* bytes, int sz) override; uint32_t getEstAirtimeFor(int len_bytes) override; bool startSendRaw(const uint8_t* bytes, int len) override; @@ -40,6 +91,11 @@ class RadioLibWrapper : public mesh::Radio { void onSendFinished() override; bool isInRecvMode() const override; bool isChannelActive(); + virtual bool supportsRxPowerSaving() const { return false; } + void setRxPowerSavingConfigError(bool invalid) override { _rx_ps_config_error = invalid; } + bool setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) override; + RxPowerSavingStatus getRxPowerSavingStatus() const override; + bool isRxPowerSavingCalibrationActive() const override { return _nf_calib_active; } bool isReceiving() override { if (isReceivingPacket()) return true; @@ -68,7 +124,12 @@ class RadioLibWrapper : public mesh::Radio { uint32_t getPacketsRecv() const { return n_recv; } uint32_t getPacketsRecvErrors() const { return n_recv_errors; } uint32_t getPacketsSent() const { return n_sent; } - void resetStats() { n_recv = n_sent = n_recv_errors = 0; } + void resetStats() { + n_recv = n_sent = n_recv_errors = n_rx_ps_arm_failures = 0; + // Clearing diagnostics also grants a fresh set of arm attempts. If they + // fail again, the normal three-failure continuous-RX fallback applies. + _rx_ps_arm_retry.reset(); + } virtual float getLastRSSI() const override; virtual float getLastSNR() const override; diff --git a/test/host/CMakeLists.txt b/test/host/CMakeLists.txt new file mode 100644 index 00000000000..82fcbb87dcd --- /dev/null +++ b/test/host/CMakeLists.txt @@ -0,0 +1,27 @@ +# Host tests for the RadioLib wrappers and radio drivers. +# cmake -S test/host -B .pio/host -DRADIOLIB_DIR= +# cmake --build .pio/host && ctest --test-dir .pio/host +cmake_minimum_required(VERSION 3.16) +project(meshcore_host_tests CXX) +if(NOT RADIOLIB_DIR) + message(FATAL_ERROR "Set RADIOLIB_DIR to a RadioLib 7.8.1 source tree, e.g. .pio/libdeps//RadioLib") +endif() +add_compile_definitions(RADIOLIB_GODMODE=1 RADIOLIB_STATIC_ONLY=1) +add_subdirectory(${RADIOLIB_DIR} radiolib) +enable_testing() + +# Keep assertions active even when a caller selects Release. +add_executable(wrapper_lifecycle wrapper_lifecycle.cpp ../../src/helpers/radiolib/RadioLibWrappers.cpp ../../src/helpers/radiolib/RXPowerSaving.cpp) +target_compile_features(wrapper_lifecycle PRIVATE cxx_std_17) +target_compile_options(wrapper_lifecycle PRIVATE -UNDEBUG -include ${CMAKE_CURRENT_SOURCE_DIR}/platform_stub.h) +target_include_directories(wrapper_lifecycle PRIVATE ../../src ../../test/mocks) +target_link_libraries(wrapper_lifecycle RadioLib) +add_test(NAME wrapper_lifecycle COMMAND wrapper_lifecycle) + +add_executable(wrapper_lifecycle_lr2021 wrapper_lifecycle.cpp ../../src/helpers/radiolib/RadioLibWrappers.cpp ../../src/helpers/radiolib/RXPowerSaving.cpp) +target_compile_definitions(wrapper_lifecycle_lr2021 PRIVATE USE_LR2021=1) +target_compile_features(wrapper_lifecycle_lr2021 PRIVATE cxx_std_17) +target_compile_options(wrapper_lifecycle_lr2021 PRIVATE -UNDEBUG -include ${CMAKE_CURRENT_SOURCE_DIR}/platform_stub.h) +target_include_directories(wrapper_lifecycle_lr2021 PRIVATE ../../src ../../test/mocks) +target_link_libraries(wrapper_lifecycle_lr2021 RadioLib) +add_test(NAME wrapper_lifecycle_lr2021 COMMAND wrapper_lifecycle_lr2021) diff --git a/test/host/platform_stub.h b/test/host/platform_stub.h new file mode 100644 index 00000000000..c5b94d286cf --- /dev/null +++ b/test/host/platform_stub.h @@ -0,0 +1,20 @@ +#pragma once +#include +#include +#include +#include +using std::min; using std::max; +inline uint32_t radio_test_millis = 100; +inline unsigned long millis() { return radio_test_millis; } +inline void delay(unsigned long ms) { radio_test_millis += ms; } +class SPIClass { public: void begin() {} }; +struct SerialStub { template void print(T) {} template void println(T) {} }; +inline SerialStub Serial; +#define LORA_FREQ 869.618f +#define LORA_BW 62.5f +#define LORA_SF 8 +#define LORA_TX_POWER 14 +inline long random(long a,long) { return a; } +inline void noInterrupts() {} +inline void interrupts() {} +#define LR2021_IRQ_DIO 9 diff --git a/test/host/wrapper_lifecycle.cpp b/test/host/wrapper_lifecycle.cpp new file mode 100644 index 00000000000..86209355c23 --- /dev/null +++ b/test/host/wrapper_lifecycle.cpp @@ -0,0 +1,75 @@ +#include +#include +#include "helpers/radiolib/RadioLibWrappers.h" +struct TestBoard : mesh::MainBoard { + uint16_t getBattMilliVolts() override {return 3700;} + const char* getManufacturerName() const override {return "test";} + void reboot() override {} + uint8_t getStartupReason() const override {return BD_STARTUP_NORMAL;} +}; +struct TestPhysical : PhysicalLayer { + void (*irq)() = nullptr; + int continuous=0, standbys=0, resets=0, power_calls=0; + int16_t setOutputPower(int8_t) override {++power_calls;return 0;} + int16_t scan_result=RADIOLIB_CHANNEL_FREE; + Module* getMod() override {return nullptr;} + void setPacketReceivedAction(void (*func)()) override {irq=func;} + int16_t setPreambleLength(size_t) override {return 0;} + int16_t startReceive() override {++continuous;return 0;} + int16_t standby() override {++standbys;return 0;} + int16_t sleep() override {++resets;return 0;} + int16_t startTransmit(const uint8_t*,size_t,uint8_t) override {return 0;} + int16_t finishTransmit() override {return 0;} + int16_t scanChannel() override {if(irq)irq();return scan_result;} + size_t getPacketLength(bool) override {return 3;} + int16_t readData(uint8_t* data,size_t len) override {memset(data,0x42,len);return 0;} + float getRSSI() override {return -110;} + float getSNR() override {return 4;} +}; +struct TestWrapper : RadioLibWrapper { + bool receiving=false; + int arms=0, stops=0; + int16_t arm_result=0; + TestWrapper(TestPhysical& p,TestBoard& b):RadioLibWrapper(p,b) {} + bool supportsRxPowerSaving() const override {return true;} + bool isReceivingPacket() override {return receiving;} + bool isPacketReady() override {return true;} + float getCurrentRSSI() override {return -110;} + void setParams(float,float,uint8_t,uint8_t) override {prepareForRadioConfig();} + int16_t armDutyCycle(RadioLibIrqFlags_t,RadioLibIrqFlags_t,uint32_t* rx,uint32_t* sl) override { + ++arms;*rx=_rx_ps_rx_us;*sl=_rx_ps_sleep_us;return arm_result; + } + int16_t stopDutyCycleHardware() override {++stops;return 0;} + void restart() {requestRestartRecv();} + void pump() {uint8_t b[256];recvRaw(b,sizeof(b));} +}; +int main() { +#ifdef USE_LR2021 + TestBoard board;TestPhysical phy;TestWrapper w(phy,board);w.begin();w.pump(); + int before=phy.continuous;int standby_before=phy.standbys; + w.setTxPower(14);assert(phy.power_calls==1);assert(phy.standbys==standby_before+1); + w.pump();assert(phy.continuous>before); + puts("PASS LR2021 setTxPower enters standby and re-arms continuous RX"); + return 0; +#else + TestBoard board;TestPhysical phy;TestWrapper w(phy,board);w.begin(); + assert(w.setRxPowerSaving(true,30000,20000));w.pump();assert(w.getRxPowerSavingStatus().armed); + uint8_t pkt[3]={};assert(w.startSendRaw(pkt,3));assert(!w.getRxPowerSavingStatus().armed); + phy.irq();assert(w.isSendComplete());w.onSendFinished();w.pump();assert(w.getRxPowerSavingStatus().armed); + puts("PASS TX -> RXPS"); + w.setCADEnabled(true);phy.scan_result=RADIOLIB_LORA_DETECTED;assert(w.isChannelActive());assert(w.getRxPowerSavingStatus().armed); + puts("PASS CAD -> RXPS"); + w.arm_result=-708;w.restart();int before=phy.continuous;w.pump(); + assert(!w.getRxPowerSavingStatus().armed);assert(phy.continuous>before);assert(w.getRxPowerSavingStatus().last_error==-708); + puts("PASS arm failure -> continuous RX with error"); + assert(w.setRxPowerSaving(false,0,0));w.pump();assert(!w.getRxPowerSavingStatus().armed); + w.setRxPowerSavingConfigError(true);assert(w.getRxPowerSavingStatus().last_error==RX_POWERSAVING_ERR_TIMING); + w.setRxPowerSavingConfigError(false);w.arm_result=0; + assert(w.setRxPowerSaving(true,30000,20000));w.pump();assert(w.getRxPowerSavingStatus().armed); + puts("PASS off/on and configuration error separation"); + assert(w.setRxPowerSaving(false,30000,20000));w.loop();w.pump(); + int standby_before=phy.standbys; + w.setTxPower(14);assert(phy.power_calls==1);assert(phy.standbys==standby_before); + puts("PASS setTxPower leaves continuous RX alone on SX126x/LR11x0"); +#endif +} From 6f4f8cbf8a536c81484f49db9fde2ef1576f76ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Fri, 16 Oct 2026 02:21:07 +0200 Subject: [PATCH 33/44] radio: count CAD busy detections and show them in 'get cad' RadioLibWrapper counts CAD scans that detect LoRa activity (not free channel results or scan errors) and resets the count with the other packet stats. 'get cad' prints "on/off; channel busy: N". ESP-NOW has no CAD and reports 0. In v17 the counter was declared but never incremented. --- src/helpers/CommonRadioPrefs.cpp | 3 ++- src/helpers/esp32/ESPNOWRadio.h | 1 + src/helpers/radiolib/RadioLibWrappers.cpp | 1 + src/helpers/radiolib/RadioLibWrappers.h | 3 +++ test/host/wrapper_lifecycle.cpp | 5 ++++- 5 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/helpers/CommonRadioPrefs.cpp b/src/helpers/CommonRadioPrefs.cpp index 41b179932a5..c59f9a3d76e 100644 --- a/src/helpers/CommonRadioPrefs.cpp +++ b/src/helpers/CommonRadioPrefs.cpp @@ -110,7 +110,8 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest } if (strcmp(command, "get cad") == 0) { - sprintf(reply, "> %s", isCadEnabled() ? "on" : "off"); + sprintf(reply, "> %s; channel busy: %lu", isCadEnabled() ? "on" : "off", + (unsigned long)radio_driver.getCADBusyCount()); return true; } if (memcmp(command, "set cad ", 8) == 0) { diff --git a/src/helpers/esp32/ESPNOWRadio.h b/src/helpers/esp32/ESPNOWRadio.h index bda44a7ebb4..437569a725e 100644 --- a/src/helpers/esp32/ESPNOWRadio.h +++ b/src/helpers/esp32/ESPNOWRadio.h @@ -24,6 +24,7 @@ class ESPNOWRadio : public mesh::Radio { void onSendFinished() override; bool isInRecvMode() const override; + uint32_t getCADBusyCount() const { return 0; } // ESP-NOW has no LoRa CAD uint32_t getPacketsRecv() const { return n_recv; } uint32_t getPacketsSent() const { return n_sent; } uint32_t getPacketsRecvErrors() const { return n_recv_errors; } diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index eb01ffd2d71..03cca4b13e7 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -351,6 +351,7 @@ bool RadioLibWrapper::isChannelActive() { // try to read a non-existent packet and count a spurious recv error. state = STATE_IDLE; startRecv(); + if (result == RADIOLIB_LORA_DETECTED) ++_cad_busy_count; if (result != RADIOLIB_CHANNEL_FREE) return true; } diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 05a5d836001..529b9c9a6cf 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -17,6 +17,7 @@ class RadioLibWrapper : public mesh::Radio, public RxPowerSavingControl { PhysicalLayer* _radio; mesh::MainBoard* _board; uint32_t n_recv, n_sent, n_recv_errors; + uint32_t _cad_busy_count = 0; int16_t _noise_floor, _threshold; float _last_rssi, _last_snr; bool _last_metrics_valid; @@ -121,11 +122,13 @@ class RadioLibWrapper : public mesh::Radio, public RxPowerSavingControl { void loop() override; + uint32_t getCADBusyCount() const { return _cad_busy_count; } uint32_t getPacketsRecv() const { return n_recv; } uint32_t getPacketsRecvErrors() const { return n_recv_errors; } uint32_t getPacketsSent() const { return n_sent; } void resetStats() { n_recv = n_sent = n_recv_errors = n_rx_ps_arm_failures = 0; + _cad_busy_count = 0; // Clearing diagnostics also grants a fresh set of arm attempts. If they // fail again, the normal three-failure continuous-RX fallback applies. _rx_ps_arm_retry.reset(); diff --git a/test/host/wrapper_lifecycle.cpp b/test/host/wrapper_lifecycle.cpp index 86209355c23..6e71d88f290 100644 --- a/test/host/wrapper_lifecycle.cpp +++ b/test/host/wrapper_lifecycle.cpp @@ -58,7 +58,10 @@ int main() { phy.irq();assert(w.isSendComplete());w.onSendFinished();w.pump();assert(w.getRxPowerSavingStatus().armed); puts("PASS TX -> RXPS"); w.setCADEnabled(true);phy.scan_result=RADIOLIB_LORA_DETECTED;assert(w.isChannelActive());assert(w.getRxPowerSavingStatus().armed); - puts("PASS CAD -> RXPS"); + assert(w.getCADBusyCount()==1); + phy.scan_result=RADIOLIB_CHANNEL_FREE;assert(!w.isChannelActive());assert(w.getCADBusyCount()==1); + phy.scan_result=-706;assert(w.isChannelActive());assert(w.getCADBusyCount()==1); + puts("PASS CAD -> RXPS and busy counter excludes errors/free channel"); w.arm_result=-708;w.restart();int before=phy.continuous;w.pump(); assert(!w.getRxPowerSavingStatus().armed);assert(phy.continuous>before);assert(w.getRxPowerSavingStatus().last_error==-708); puts("PASS arm failure -> continuous RX with error"); From 61fdfafa7787c610113f2ee893ae7b3f2e862c8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Fri, 16 Oct 2026 09:01:23 +0200 Subject: [PATCH 34/44] rxps: keep RX during noise-floor calibration and bound arm retries - Calibration: the periodic noise-floor/AGC calibration runs in continuous RX, then the duty cycle is re-armed. A frame that starts during calibration is not aborted, and RSSI/SNR of the last packet are cached so they can be read while the radio sleeps. - Mode changes: a change of RX mode waits for a frame in flight or a TX to finish. RX restart requests keep an interrupt that the ISR raises meanwhile. - Retries: after three failed arm attempts the radio stays in continuous RX until the RXPS setting changes, instead of paying for a failing SPI round-trip on every RX restart. - Errors: continuous RX is started through a virtual startContinuousReceive(), whose result is reported as the RXPS 'err' status (RXPS off, during calibration, after the retry limit and as the arm-failure fallback). From PowerSaving-v17 0965a6aa and be4b2769. --- src/helpers/radiolib/RadioLibWrappers.cpp | 151 ++++++++++++++++++---- src/helpers/radiolib/RadioLibWrappers.h | 2 + test/host/wrapper_lifecycle.cpp | 32 ++++- 3 files changed, 157 insertions(+), 28 deletions(-) diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 03cca4b13e7..30ce3678a1c 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -93,20 +93,56 @@ void RadioLibWrapper::doResetAGC() { } void RadioLibWrapper::resetAGC() { - // make sure we're not mid-receive of packet! - if ((state & STATE_INT_READY) != 0 || isReceivingPacket()) return; + // make sure we're not mid-receive or mid-transmit of a packet + if (isPacketPendingOrReceiving() || state == STATE_TX_WAIT) return; if (_rx_ps_armed) stopReceiveDutyCycle(); + doResetAGC(); - state = STATE_IDLE; // trigger a startReceive() + state = STATE_IDLE; - // Reset noise floor sampling so it reconverges from scratch. - // Without this, a stuck _noise_floor of -120 makes the sampling threshold - // too low (-106) to accept normal samples (~-105), self-reinforcing the - // stuck value even after the receiver has recovered. + // Recalibrate synchronously so callers never observe the temporary zero + // used to bypass a stale sampling threshold after an AGC reset. + const int16_t previous_noise_floor = _noise_floor; _noise_floor = 0; _num_floor_samples = 0; _floor_sample_sum = 0; + + _nf_calib_active = true; // force startReceiveMode() into continuous RX + bool packet_in_progress = false; + int16_t err = startReceiveMode(); + if (err == RADIOLIB_ERR_NONE) { + state = STATE_RX; + delay(NF_CALIB_SETTLE_MS); + + uint16_t sample_attempts = 0; + while (_num_floor_samples < NUM_NOISE_FLOOR_SAMPLES && + sample_attempts++ < NF_CALIB_MAX_SAMPLE_ATTEMPTS) { + if (isPacketPendingOrReceiving()) { + packet_in_progress = true; + break; + } + sampleNoiseFloorOnce(); + if (_num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { + delay(NF_CALIB_SAMPLE_INTERVAL_MS); + } + } + } + + if (!publishNoiseFloor()) { + _noise_floor = previous_noise_floor; + _num_floor_samples = 0; + _floor_sample_sum = 0; + } + + _nf_calib_active = false; + _nf_last_calib = millis(); + + // A frame can start after resetAGC()'s initial idle check. Keep continuous + // RX alive until recvRaw() consumes it; restarting RX here would abort a + // frame that is between preamble detection and RX_DONE. + if (!packet_in_progress) packet_in_progress = isPacketPendingOrReceiving(); + if (!packet_in_progress) requestRestartRecv(); } @@ -133,25 +169,71 @@ bool RadioLibWrapper::isPacketPendingOrReceiving() { -void RadioLibWrapper::loop() { - if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { - if (!_rx_ps_armed && !isReceivingPacket()) { - int rssi = getCurrentRSSI(); - if (rssi < _noise_floor + SAMPLING_THRESHOLD) { // only consider samples below current floor + sampling THRESHOLD - _num_floor_samples++; - _floor_sample_sum += rssi; - } - } - } else if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES && _floor_sample_sum != 0) { - _noise_floor = _floor_sample_sum / NUM_NOISE_FLOOR_SAMPLES; - if (_noise_floor < -120) { - _noise_floor = -120; // clamp to lower bound of -120dBi +void RadioLibWrapper::sampleNoiseFloorOnce() { + int rssi = getCurrentRSSI(); + if (rssi < _noise_floor + SAMPLING_THRESHOLD) { + _num_floor_samples++; + _floor_sample_sum += rssi; + } +} + +bool RadioLibWrapper::publishNoiseFloor() { + if (_num_floor_samples < NUM_NOISE_FLOOR_SAMPLES || _floor_sample_sum == 0) return false; + + _noise_floor = _floor_sample_sum / NUM_NOISE_FLOOR_SAMPLES; + if (_noise_floor < -120) { + _noise_floor = -120; // clamp to lower bound of -120dBi + } + _floor_sample_sum = 0; + + #ifdef MESH_DEBUG_NOISE_FLOOR + MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor); + #endif + return true; +} + +void RadioLibWrapper::noiseFloorCalibCheck() { + unsigned long now = millis(); + if (_nf_calib_active) { + if (!_rx_ps_enabled || (long)(now - _nf_calib_deadline) >= 0) { + endNoiseFloorCalib(now); + } else if (_rx_ps_armed && !isPacketPendingOrReceiving()) { + // A packet may have delayed the RXPS-to-continuous-RX transition. + requestRestartRecv(); } + } else if (_rx_ps_enabled && _rx_ps_armed && state == STATE_RX && + (_nf_last_calib == 0 || now - _nf_last_calib >= NF_CALIB_INTERVAL_MS) && + !isPacketPendingOrReceiving()) { + _nf_calib_active = true; + _nf_calib_deadline = now + NF_CALIB_TIMEOUT_MS; + _nf_sample_from = now + NF_CALIB_SETTLE_MS; + _num_floor_samples = 0; _floor_sample_sum = 0; + if (!isPacketPendingOrReceiving()) requestRestartRecv(); + } +} - #ifdef MESH_DEBUG_NOISE_FLOOR - MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor); - #endif +void RadioLibWrapper::endNoiseFloorCalib(unsigned long now) { + _nf_calib_active = false; + _nf_last_calib = now; + if (!isPacketPendingOrReceiving()) requestRestartRecv(); +} + +void RadioLibWrapper::loop() { + if (_rx_ps_restart_pending && (state & ~STATE_INT_READY) != STATE_TX_WAIT && + !isPacketPendingOrReceiving()) { + _rx_ps_restart_pending = false; + requestRestartRecv(); + } + noiseFloorCalibCheck(); + + if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { + if (!_rx_ps_armed && !(_nf_calib_active && (long)(millis() - _nf_sample_from) < 0) && + !isReceivingPacket()) { + sampleNoiseFloorOnce(); + } + } else if (publishNoiseFloor()) { + if (_nf_calib_active) endNoiseFloorCalib(millis()); } } @@ -177,7 +259,18 @@ int16_t RadioLibWrapper::startReceiveMode() { if (_rx_ps_armed) stopReceiveDutyCycle(); if (!_rx_ps_enabled || _nf_calib_active) { _rx_ps_armed = false; - return _radio->startReceive(); + const int16_t err = startContinuousReceive(); + _rx_ps_last_error = err; + return err; + } + + if (!_rx_ps_arm_retry.canAttempt()) { + // arming has failed repeatedly; stop paying for a doomed SPI round-trip on + // every RX restart. A config change (setRxPowerSaving) re-enables retries. + _rx_ps_armed = false; + const int16_t err = startContinuousReceive(); + if (err != RADIOLIB_ERR_NONE) _rx_ps_last_error = err; + return err; } const RadioLibIrqFlags_t irq_flags = @@ -195,6 +288,7 @@ int16_t RadioLibWrapper::startReceiveMode() { if (err == RADIOLIB_ERR_NONE) { _rx_ps_armed = true; _rx_ps_last_error = RADIOLIB_ERR_NONE; + _rx_ps_arm_retry.recordSuccess(); _rx_ps_eff_rx_us = eff_rx_us; _rx_ps_eff_sleep_us = eff_sleep_us; return err; @@ -205,9 +299,10 @@ int16_t RadioLibWrapper::startReceiveMode() { _rx_ps_eff_rx_us = 0; _rx_ps_eff_sleep_us = 0; n_rx_ps_arm_failures++; + _rx_ps_arm_retry.recordFailure(); MESH_DEBUG_PRINTLN("RadioLibWrapper: startReceiveDutyCycle(%d), continuous RX fallback", err); stopDutyCycleHardware(); - int16_t fallback_err = _radio->startReceive(); + int16_t fallback_err = startContinuousReceive(); if (fallback_err != RADIOLIB_ERR_NONE) _rx_ps_last_error = fallback_err; return fallback_err; } @@ -246,7 +341,11 @@ bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sl _rx_ps_sleep_us = sleep_us; _rx_ps_last_error = RADIOLIB_ERR_NONE; _rx_ps_arm_retry.reset(); // new config, give the hardware a fresh chance - requestRestartRecv(); + _rx_ps_restart_pending = true; + if ((state & ~STATE_INT_READY) != STATE_TX_WAIT && !isPacketPendingOrReceiving()) { + _rx_ps_restart_pending = false; + requestRestartRecv(); + } return true; } diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 529b9c9a6cf..666375c6973 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -26,6 +26,7 @@ class RadioLibWrapper : public mesh::Radio, public RxPowerSavingControl { int32_t _floor_sample_sum; uint8_t _preamble_sf; + bool _rx_ps_restart_pending = false; bool _rx_ps_config_error = false; bool _rx_ps_enabled; bool _rx_ps_armed; @@ -65,6 +66,7 @@ class RadioLibWrapper : public mesh::Radio, public RxPowerSavingControl { return RADIOLIB_ERR_UNSUPPORTED; } virtual int16_t stopDutyCycleHardware() { return _radio->standby(); } + virtual int16_t startContinuousReceive() { return _radio->startReceive(); } virtual bool isPacketReady(); virtual bool isChipBusy() { return false; } float packetScoreInt(float snr, int sf, int packet_len); diff --git a/test/host/wrapper_lifecycle.cpp b/test/host/wrapper_lifecycle.cpp index 6e71d88f290..1db28f033af 100644 --- a/test/host/wrapper_lifecycle.cpp +++ b/test/host/wrapper_lifecycle.cpp @@ -10,12 +10,13 @@ struct TestBoard : mesh::MainBoard { struct TestPhysical : PhysicalLayer { void (*irq)() = nullptr; int continuous=0, standbys=0, resets=0, power_calls=0; + int16_t continuous_result=0; int16_t setOutputPower(int8_t) override {++power_calls;return 0;} int16_t scan_result=RADIOLIB_CHANNEL_FREE; Module* getMod() override {return nullptr;} void setPacketReceivedAction(void (*func)()) override {irq=func;} int16_t setPreambleLength(size_t) override {return 0;} - int16_t startReceive() override {++continuous;return 0;} + int16_t startReceive() override {++continuous;return continuous_result;} int16_t standby() override {++standbys;return 0;} int16_t sleep() override {++resets;return 0;} int16_t startTransmit(const uint8_t*,size_t,uint8_t) override {return 0;} @@ -28,13 +29,14 @@ struct TestPhysical : PhysicalLayer { }; struct TestWrapper : RadioLibWrapper { bool receiving=false; + bool inject_frame=false; int arms=0, stops=0; int16_t arm_result=0; TestWrapper(TestPhysical& p,TestBoard& b):RadioLibWrapper(p,b) {} bool supportsRxPowerSaving() const override {return true;} bool isReceivingPacket() override {return receiving;} bool isPacketReady() override {return true;} - float getCurrentRSSI() override {return -110;} + float getCurrentRSSI() override {if(inject_frame){receiving=true;inject_frame=false;}return -110;} void setParams(float,float,uint8_t,uint8_t) override {prepareForRadioConfig();} int16_t armDutyCycle(RadioLibIrqFlags_t,RadioLibIrqFlags_t,uint32_t* rx,uint32_t* sl) override { ++arms;*rx=_rx_ps_rx_us;*sl=_rx_ps_sleep_us;return arm_result; @@ -50,6 +52,9 @@ int main() { w.setTxPower(14);assert(phy.power_calls==1);assert(phy.standbys==standby_before+1); w.pump();assert(phy.continuous>before); puts("PASS LR2021 setTxPower enters standby and re-arms continuous RX"); + phy.continuous_result=-707;w.restart();w.pump();assert(w.getRxPowerSavingStatus().last_error==-707); + phy.continuous_result=0;w.restart();w.pump();assert(w.getRxPowerSavingStatus().last_error==0); + puts("PASS LR2021 continuous RX error and recovery"); return 0; #else TestBoard board;TestPhysical phy;TestWrapper w(phy,board);w.begin(); @@ -70,9 +75,32 @@ int main() { w.setRxPowerSavingConfigError(false);w.arm_result=0; assert(w.setRxPowerSaving(true,30000,20000));w.pump();assert(w.getRxPowerSavingStatus().armed); puts("PASS off/on and configuration error separation"); + w.arm_result=-708;assert(w.setRxPowerSaving(true,30000,20000));int arms_before=w.arms; + for(int i=0;i<8;++i){w.restart();w.pump();} + assert(w.arms-arms_before==3);assert(!w.getRxPowerSavingStatus().armed); + puts("PASS retry limit: three failures then continuous RX"); + w.arm_result=0;assert(w.setRxPowerSaving(true,30000,20000));w.pump(); + assert(w.getRxPowerSavingStatus().armed); + w.receiving=true;int stops_before=w.stops; + assert(w.setRxPowerSaving(false,30000,20000));w.loop();w.pump(); + assert(w.stops==stops_before);assert(w.getRxPowerSavingStatus().armed); + w.receiving=false;phy.irq();w.pump();assert(!w.getRxPowerSavingStatus().armed); + puts("PASS mode change preserves in-flight RX"); + assert(w.setRxPowerSaving(true,30000,20000));w.loop();w.pump(); + w.inject_frame=true;w.resetAGC(); + assert(w.receiving);assert(!w.getRxPowerSavingStatus().armed); + int continuous_after=phy.continuous;w.pump();assert(phy.continuous==continuous_after); + w.receiving=false;phy.irq();w.pump();assert(w.getRxPowerSavingStatus().armed); + puts("PASS frame starting during AGC calibration is retained"); + w.resetAGC();assert(w.getNoiseFloor()==-110);w.pump();assert(w.getRxPowerSavingStatus().armed); + puts("PASS successful AGC calibration restores RXPS and publishes floor"); assert(w.setRxPowerSaving(false,30000,20000));w.loop();w.pump(); int standby_before=phy.standbys; w.setTxPower(14);assert(phy.power_calls==1);assert(phy.standbys==standby_before); puts("PASS setTxPower leaves continuous RX alone on SX126x/LR11x0"); + phy.continuous_result=-707;w.restart();w.pump(); + assert(w.getRxPowerSavingStatus().last_error==-707); + phy.continuous_result=0;w.restart();w.pump();assert(w.getRxPowerSavingStatus().last_error==0); + puts("PASS continuous RX failure and recovery are reported"); #endif } From d9251f0aff0036067ee0719d5959bda9b11d0a12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Fri, 16 Oct 2026 10:09:32 +0200 Subject: [PATCH 35/44] rxps: LR1110 RX duty cycle with 64-bit timing and LF clock restore CustomLR1110 arms the duty cycle with its own period conversion. The stock RadioLib 7.8.1 path multiplies the period by 32768 in 32 bits and overflows above ~131 ms. Arming switches the LF clock to RC with BUSY release. Stopping the duty cycle restores the XOSC LF clock with BUSY release off. Every continuous RX first enters standby and restores the clock, so a failed restore cannot leave continuous RX on the RC clock. driver_spi in test/host checks the SPI commands that the SX1262 and LR1110 drivers send for a range of periods, including the 6553-tick LR1110 command for 200 ms. It also checks injected command errors, the stop paths, and that every calculator profile yields valid periods above the radio's transition time. From PowerSaving-v17 307bc938. --- src/helpers/radiolib/CustomLR1110.h | 79 ++++++++++++++ src/helpers/radiolib/CustomLR1110Wrapper.h | 50 ++++++++- test/host/CMakeLists.txt | 7 ++ test/host/driver_spi.cpp | 115 +++++++++++++++++++++ 4 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 test/host/driver_spi.cpp diff --git a/src/helpers/radiolib/CustomLR1110.h b/src/helpers/radiolib/CustomLR1110.h index f0fde435400..8c735a75357 100644 --- a/src/helpers/radiolib/CustomLR1110.h +++ b/src/helpers/radiolib/CustomLR1110.h @@ -2,6 +2,7 @@ #include #include "MeshCore.h" +#include "RXPowerSaving.h" class CustomLR1110 : public LR1110 { uint32_t _preambleMillis = 66; @@ -13,16 +14,27 @@ class CustomLR1110 : public LR1110 { public: CustomLR1110(Module *mod) : LR1110(mod) { } + // See CustomSX1262::begin - same reasoning, LR11x0 flags and calibration. int16_t begin(float freq = 434.0, float bw = 125.0, uint8_t sf = 9, uint8_t cr = 7, uint8_t syncWord = RADIOLIB_LR11X0_LORA_SYNC_WORD_PRIVATE, int8_t power = 10, uint16_t preambleLength = 8, float tcxoVoltage = 1.6) { int16_t state = LR1110::begin(freq, bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage); + if (state == RADIOLIB_ERR_NONE) state = applyMeshCoreTcxoDelay(); // RadioLib begin() defaults to LDO; use the LR1110 DC/DC regulator. if (state == RADIOLIB_ERR_NONE) state = setRegulatorDCDC(); return state; } + int16_t applyMeshCoreTcxoDelay() { + if (tcxoVoltage <= 0.0f) return RADIOLIB_ERR_NONE; + int16_t state = setTCXO(tcxoVoltage, MC_TCXO_DELAY_US); + RADIOLIB_ASSERT(state); + state = calibrate(0x3F); + delay(50); + return state; + } + size_t getPacketLength(bool update) override { size_t len = LR1110::getPacketLength(update); if (len == 0 && getIrqStatus() & RADIOLIB_LR11X0_IRQ_HEADER_ERR) { @@ -38,6 +50,68 @@ class CustomLR1110 : public LR1110 { float getFreqMHz() const { return freqMHz; } + // The sleep -> RX transition RadioLib subtracts from a duty-cycle sleep. + // Protected in the RadioLib base, so the RXPS layer reads it through here. + uint32_t getTcxoDelay() const { return tcxoDelay; } + + // Restores the LF clock configuration RadioLib's begin() installs. Arming + // the duty cycle switches it to RC + BUSY-release (required by Semtech for + // SetRxDutyCycle); without this the change would outlive RXPS being turned + // off, and it also alters what the BUSY pin means to isChipBusy(). + int16_t restoreLfClock() { + return configLfClock( + RADIOLIB_LR11X0_LF_BUSY_RELEASE_DISABLED | RADIOLIB_LR11X0_LF_CLK_XOSC); + } + + // effectiveRxPeriod reports the RX window actually programmed, which may be + // stretched above the requested value to satisfy the extended-period rule. + int16_t startReceiveDutyCycle(uint32_t rxPeriod, uint32_t sleepPeriod, + RadioLibIrqFlags_t irqFlags = RADIOLIB_IRQ_RX_DEFAULT_FLAGS, + RadioLibIrqFlags_t irqMask = RADIOLIB_IRQ_RX_DEFAULT_MASK, + uint32_t* effectiveRxPeriod = nullptr) { + // Keep the conversion local until RadioLib uses 64-bit multiplication; + // its pinned implementation overflows for longer microsecond periods. + uint32_t symbolPeriod = (uint32_t)(((1000.0f * (float)(1UL << this->spreadingFactor)) / + this->bandwidthKhz) + 0.999f); + uint32_t transitionTime = this->tcxoDelay + 1000; + if (sleepPeriod <= transitionTime) return RADIOLIB_ERR_INVALID_SLEEP_PERIOD; + uint32_t programmedSleepPeriod = sleepPeriod - transitionTime; + + uint64_t requiredExtendedPeriod = + ((uint64_t)this->preambleLengthLoRa + 11ULL) * symbolPeriod + 1000ULL; + uint64_t extendedPeriod = 2ULL * rxPeriod + programmedSleepPeriod; + if (extendedPeriod < requiredExtendedPeriod) { + rxPeriod = (uint32_t)((requiredExtendedPeriod - programmedSleepPeriod + 1ULL) / 2ULL); + } + if (effectiveRxPeriod) *effectiveRxPeriod = rxPeriod; + + uint32_t rxPeriodRaw = (uint32_t)(((uint64_t)rxPeriod * 32768UL) / 1000000UL); + uint32_t sleepPeriodRaw = + (uint32_t)(((uint64_t)programmedSleepPeriod * 32768UL) / 1000000UL); + if ((rxPeriodRaw & 0xFF000000) || rxPeriodRaw == 0) return RADIOLIB_ERR_INVALID_RX_PERIOD; + if ((sleepPeriodRaw & 0xFF000000) || sleepPeriodRaw == 0) { + return RADIOLIB_ERR_INVALID_SLEEP_PERIOD; + } + + int16_t state = standby(RADIOLIB_LR11X0_STANDBY_RC); + RADIOLIB_ASSERT(state); + // Semtech requires the RC standby/RTC setup before SetRxDutyCycle. + state = configLfClock(RADIOLIB_LR11X0_LF_CLK_RC | RADIOLIB_LR11X0_LF_BUSY_RELEASE_ENABLED); + RADIOLIB_ASSERT(state); + RadioModeConfig_t cfg = { + .receive = { + .timeout = RADIOLIB_LR11X0_RX_TIMEOUT_INF, + .irqFlags = irqFlags, + .irqMask = irqMask, + .len = 0, + } + }; + + state = stageMode(RADIOLIB_RADIO_MODE_RX, &cfg); + RADIOLIB_ASSERT(state); + return setRxDutyCycle(rxPeriodRaw, sleepPeriodRaw, RADIOLIB_LR11X0_RX_DUTY_CYCLE_MODE_RX); + } + int16_t setRxBoostedGainMode(bool en) { _rx_boosted = en; return LR1110::setRxBoostedGainMode(en); @@ -50,6 +124,11 @@ class CustomLR1110 : public LR1110 { return LR1110::startReceive(RADIOLIB_LR11X0_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); } + bool isChipBusy() { + uint32_t busy = this->mod->getGpio(); + return busy != RADIOLIB_NC && this->mod->hal->digitalRead(busy); + } + bool isReceiving() { uint32_t irq = getIrqStatus(); bool preamble = irq & RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED; // bit 4 diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index e7aaeb937a7..ffa5c6d8516 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -6,9 +6,24 @@ class CustomLR1110Wrapper : public RadioLibWrapper { public: + // Measured on ThinkNode M3 and T1000-E: this family needs roughly one symbol + // more margin than SX126x before a duty-cycled receiver latches a preamble. + float rxPowerSavingCaptureCostSymbols() const override { + return RX_POWERSAVING_CAPTURE_COST_SYMBOLS_LR11X0; + } + + // Same reasoning as the SX126x wrapper. This family never showed either + // duty-cycle failure on the bench, which is unsurprising: its driver has + // always enforced an extended-period rule equivalent to the timer guard the + // SX126x side only just gained. + uint32_t rxPowerSavingTransitionUs() const override { + return ((CustomLR1110 *)_radio)->getTcxoDelay() + 1000; + } + CustomLR1110Wrapper(CustomLR1110& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + prepareForRadioConfig(); ((CustomLR1110 *)_radio)->setFrequency(freq); ((CustomLR1110 *)_radio)->setSpreadingFactor(sf); ((CustomLR1110 *)_radio)->setBandwidth(bw); @@ -20,8 +35,16 @@ class CustomLR1110Wrapper : public RadioLibWrapper { } bool isReceivingPacket() override { + // While duty-cycling, BUSY marks the sleep phase: probing IRQ status over + // SPI there could wake the chip and break the cycle. Only skip the probe + // in that case - outside RXPS this must stay a real channel check, since + // Dispatcher::checkSend() relies on it to avoid transmitting over others. + if (_rx_ps_armed && ((CustomLR1110 *)_radio)->isChipBusy()) return false; return ((CustomLR1110 *)_radio)->isReceiving(); } + bool isChipBusy() override { + return ((CustomLR1110 *)_radio)->isChipBusy(); + } float getCurrentRSSI() override { float rssi = -110; ((CustomLR1110 *)_radio)->getRssiInst(&rssi); @@ -38,12 +61,35 @@ class CustomLR1110Wrapper : public RadioLibWrapper { _radio->setPreambleLength(preambleLengthForSF(getSpreadingFactor())); // overcomes weird issues with small and big pkts } - float getLastRSSI() const override { return ((CustomLR1110 *)_radio)->getRSSI(); } - float getLastSNR() const override { return ((CustomLR1110 *)_radio)->getSNR(); } + bool supportsRxPowerSaving() const override { return true; } + +protected: + int16_t startContinuousReceive() override { + // Also covers a stopDutyCycleHardware() whose clock restore failed. + int16_t err = _radio->standby(); + if (err == RADIOLIB_ERR_NONE) err = ((CustomLR1110*)_radio)->restoreLfClock(); + return err == RADIOLIB_ERR_NONE ? _radio->startReceive() : err; + } + + int16_t armDutyCycle(RadioLibIrqFlags_t irq_flags, RadioLibIrqFlags_t irq_mask, + uint32_t* eff_rx_us, uint32_t* eff_sleep_us) override { + *eff_sleep_us = _rx_ps_sleep_us; // only the RX window can get clamped + return ((CustomLR1110 *)_radio)->startReceiveDutyCycle( + _rx_ps_rx_us, _rx_ps_sleep_us, irq_flags, irq_mask, eff_rx_us); + } + + int16_t stopDutyCycleHardware() override { + const int16_t state = _radio->standby(); + const int16_t clock = ((CustomLR1110 *)_radio)->restoreLfClock(); + return state != RADIOLIB_ERR_NONE ? state : clock; + } + +public: uint8_t getSpreadingFactor() const override { return ((CustomLR1110 *)_radio)->getSpreadingFactor(); } bool setRxBoostedGainMode(bool en) override { + prepareForRadioConfig(); return ((CustomLR1110 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } bool getRxBoostedGainMode() const override { diff --git a/test/host/CMakeLists.txt b/test/host/CMakeLists.txt index 82fcbb87dcd..e8485782485 100644 --- a/test/host/CMakeLists.txt +++ b/test/host/CMakeLists.txt @@ -25,3 +25,10 @@ target_compile_options(wrapper_lifecycle_lr2021 PRIVATE -UNDEBUG -include ${CMAK target_include_directories(wrapper_lifecycle_lr2021 PRIVATE ../../src ../../test/mocks) target_link_libraries(wrapper_lifecycle_lr2021 RadioLib) add_test(NAME wrapper_lifecycle_lr2021 COMMAND wrapper_lifecycle_lr2021) + +add_executable(driver_spi driver_spi.cpp ../../src/helpers/radiolib/RXPowerSaving.cpp) +target_compile_features(driver_spi PRIVATE cxx_std_17) +target_compile_options(driver_spi PRIVATE -UNDEBUG -include ${CMAKE_CURRENT_SOURCE_DIR}/platform_stub.h) +target_include_directories(driver_spi PRIVATE ../../test/mocks ../../src ../../src/helpers/radiolib) +target_link_libraries(driver_spi RadioLib) +add_test(NAME driver_spi COMMAND driver_spi) diff --git a/test/host/driver_spi.cpp b/test/host/driver_spi.cpp new file mode 100644 index 00000000000..34e7358d149 --- /dev/null +++ b/test/host/driver_spi.cpp @@ -0,0 +1,115 @@ +#include +#include +#include +#include "CustomSX1262.h" +#include "CustomLR1110.h" +class TraceHal : public RadioLibHal { +public: + bool lr=false; uint16_t pending=0; uint8_t error=0; unsigned long now=0; + std::vector> traces; + uint32_t pins[32]={}; + TraceHal(bool islr): RadioLibHal(0,1,0,1,1,2),lr(islr) {} + void pinMode(uint32_t,uint32_t) override {} + void digitalWrite(uint32_t p,uint32_t v) override {if(p<32)pins[p]=v;} + uint32_t digitalRead(uint32_t) override {return 0;} + void attachInterrupt(uint32_t,void(*)(void),uint32_t) override {} + void detachInterrupt(uint32_t) override {} + void delay(RadioLibTime_t t) override {now+=t*1000;} + void delayMicroseconds(RadioLibTime_t t) override {now+=t;} + RadioLibTime_t millis() override {return now++/1000;} + RadioLibTime_t micros() override {return now++;} + long pulseIn(uint32_t,uint32_t,RadioLibTime_t) override {return 0;} + void spiBegin() override {} + void spiBeginTransaction() override {} + void spiEndTransaction() override {} + void spiEnd() override {} + void spiTransfer(uint8_t* out,size_t len,uint8_t* in) override { + traces.emplace_back(out,out+len); memset(in,0,len); + if(lr){ + if(len)in[0]=error?error:0x04; + if(len>=2 && out[0]){pending=(uint16_t(out[0])<<8)|out[1];} + else if(pending && len>=2){ + if(pending==RADIOLIB_LR11X0_CMD_GET_PACKET_TYPE)in[1]=RADIOLIB_LR11X0_PACKET_TYPE_LORA; + pending=0; + } + }else{ + if(len>1)in[1]=error?error:0x24; + if(len>2 && out[0]==RADIOLIB_SX126X_CMD_GET_PACKET_TYPE)in[2]=RADIOLIB_SX126X_PACKET_TYPE_LORA; + } + } + void output(const char* name,int result,uint32_t eff=0){ + printf("%s result=%d eff=%u",name,result,eff); + for(auto& t:traces){printf(" |");for(auto b:t)printf("%02x",b);} + printf("\n"); traces.clear(); + } +}; +struct LRProbe : CustomLR1110 { + using CustomLR1110::CustomLR1110; + using LRxxxx::tcxoDelay; + using LRxxxx::spreadingFactor; + using LRxxxx::bandwidthKhz; + using LRxxxx::preambleLengthLoRa; + using LRxxxx::headerType; + using LRxxxx::implicitLen; + using LRxxxx::crcTypeLoRa; + using LRxxxx::invertIQEnabled; +}; +int main(){ + TraceHal hs(false),hl(true); Module ms(&hs,1,2,3,4),ml(&hl,1,2,3,4); + CustomSX1262 sx(&ms); LRProbe lr(&ml); + ms.spiConfig.stream=true;ms.spiConfig.statusPos=1; + ms.spiConfig.widths[RADIOLIB_MODULE_SPI_WIDTH_ADDR]=Module::BITS_16; + ms.spiConfig.widths[RADIOLIB_MODULE_SPI_WIDTH_CMD]=Module::BITS_8; + ms.spiConfig.parseStatusCb=SX126x::SPIparseStatus; + ms.spiConfig.cmds[RADIOLIB_MODULE_SPI_COMMAND_READ]=RADIOLIB_SX126X_CMD_READ_REGISTER; + ms.spiConfig.cmds[RADIOLIB_MODULE_SPI_COMMAND_WRITE]=RADIOLIB_SX126X_CMD_WRITE_REGISTER; + ms.spiConfig.cmds[RADIOLIB_MODULE_SPI_COMMAND_NOP]=0; + ms.spiConfig.cmds[RADIOLIB_MODULE_SPI_COMMAND_STATUS]=RADIOLIB_SX126X_CMD_GET_STATUS; + ml.spiConfig.widths[RADIOLIB_MODULE_SPI_WIDTH_ADDR]=Module::BITS_32; + sx.tcxoDelay=lr.tcxoDelay=1600; + sx.spreadingFactor=lr.spreadingFactor=8;sx.bandwidthKhz=lr.bandwidthKhz=62.5; + sx.preambleLengthLoRa=lr.preambleLengthLoRa=16; + sx.headerType=lr.headerType=0;sx.implicitLen=lr.implicitLen=255; + sx.crcTypeLoRa=lr.crcTypeLoRa=1;sx.invertIQEnabled=lr.invertIQEnabled=false; + sx.setRfSwitchPins(10,11); + for(auto periods:std::vector>{{1000,1000},{1000,2600},{1000,2850},{30000,20000},{200000,200000},{30000000,30000000}}){ + char name[90];snprintf(name,sizeof(name),"SX rx=%u sleep=%u",periods.first,periods.second); + int r=sx.startReceiveDutyCycle(periods.first,periods.second);hs.output(name,r); + if(periods.second>2600)assert(r==0 && hs.pins[10]==1 && hs.pins[11]==0); + else assert(r==RADIOLIB_ERR_INVALID_SLEEP_PERIOD); + uint32_t eff=0;snprintf(name,sizeof(name),"LR rx=%u sleep=%u",periods.first,periods.second); + r=lr.startReceiveDutyCycle(periods.first,periods.second,RADIOLIB_IRQ_RX_DEFAULT_FLAGS,RADIOLIB_IRQ_RX_DEFAULT_MASK,&eff); + if(periods.first==200000 && r==0) { + bool found=false; + for(auto& cmd:hl.traces) if(cmd.size()>=8 && cmd[0]==0x02 && cmd[1]==0x14) { + unsigned ticks=(unsigned(cmd[2])<<16)|(unsigned(cmd[3])<<8)|cmd[4]; + assert(ticks==6553);found=true; + } + assert(found); + } + hl.output(name,r,eff); + if(periods.second>2600)assert(r==0); + else assert(r==RADIOLIB_ERR_INVALID_SLEEP_PERIOD); + } + int st=sx.getStatus(); assert(st==0x24); hs.output("SX getStatus",st); + hs.error=0x28; st=sx.startReceiveDutyCycle(30000,20000); + assert(st==RADIOLIB_ERR_SPI_CMD_INVALID);hs.output("SX injected command error",st);hs.error=0; + hl.error=0x02; st=lr.startReceiveDutyCycle(30000,20000); + assert(st==RADIOLIB_ERR_SPI_CMD_INVALID);hl.output("LR injected command error",st);hl.error=0; + st=sx.stopRTC();assert(st==0);hs.output("SX stopRTC",st); + st=lr.restoreLfClock();assert(st==0); + assert(!hl.traces.empty() && hl.traces.front()==std::vector({0x01,0x16,0x01})); + hl.output("LR restoreLF",st); + unsigned valid=0,invalid=0; + for(float bw:{7.8f,10.4f,15.6f,20.8f,31.25f,41.7f,62.5f,125.0f,250.0f,500.0f}) + for(uint8_t sf=5;sf<=12;sf++)for(uint8_t p:{16,32})for(uint8_t level=1;level<=10;level++) + for(float cost:{6.f,8.f})for(uint32_t transition:{1000u,2600u,6000u}){ + uint32_t rx=0,sl=0;bool ok=recalcRxPowerSavingFromLevel(level,sf,bw,p,&rx,&sl,cost,transition); + if(ok) { + assert(isValidRxPowerSavingPeriod(rx) && isValidRxPowerSavingPeriod(sl)); + assert(sl>transition); + valid++; + } else invalid++; + } + printf("PROFILE MATRIX valid=%u rejected=%u\n",valid,invalid); +} From ed6b781035177a93fbc9881480320677eafdf72b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Fri, 16 Oct 2026 11:26:49 +0200 Subject: [PATCH 36/44] rxps: persistent settings and 'radio.rxps' CLI for all roles Both NodePrefs types (repeater/room/sensor and companion) store the RXPS config under the v17 JSON keys: rxps_en, rxps_rx_us, rxps_sleep_us, rxps_level and rxps_preamble. Fresh installs start with Balanced (level 6, preamble 16); a saved setting, including off, wins. CommonRadioPrefs handles 'get radio.rxps' and 'set radio.rxps' for every role and applies the config to the RadioLib wrapper. Temporary radio parameters do not overwrite the stored automatic periods. 'set radio' re-checks the RXPS timing for the new SF/BW. If the profile no longer fits, the node drops to continuous RX with err=-10001 while the saved choice is kept. config_lifecycle in test/host (GoogleTest) loads and saves the real NodePrefs of both types through the shared CLI. From PowerSaving-v17 aa159334 and b1119e30. --- docs/cli_commands.md | 15 ++++ examples/companion_radio/NodePrefs.h | 9 +++ src/helpers/CommonCLI.h | 9 +++ src/helpers/CommonRadioPrefs.cpp | 56 ++++++++++++- src/helpers/CommonRadioPrefs.h | 7 ++ test/host/CMakeLists.txt | 14 +++- test/host/config_lifecycle.cpp | 114 +++++++++++++++++++++++++++ test/host/config_target/Arduino.h | 7 ++ test/host/config_target/CayenneLPP.h | 2 + test/host/config_target/target.h | 20 +++++ 10 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 test/host/config_lifecycle.cpp create mode 100644 test/host/config_target/Arduino.h create mode 100644 test/host/config_target/CayenneLPP.h create mode 100644 test/host/config_target/target.h diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 061d6aaecb6..dd7fb775c25 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1203,6 +1203,21 @@ Ethernet support is available on RAK4631 boards with a RAK13800 (W5100S) Etherne --- +### RX power saving (RXPS) + +Repeater, Room Server, Sensor and Companion. SX1262 and LR1110 receive in a duty cycle; LR1121, LR2021 and SX127x stay in continuous RX (`supported=no`). + +- `get radio.rxps`: `desired` (saved on/off), `effective` (`armed` or `continuous`), `supported`, `err` (last error), `fail` (arm failures), `level`, `catch` (preamble symbols still caught), `preamble`, `rx`/`sleep` (µs). `erx`/`eslp` appear when the driver had to clamp the periods. +- `set radio.rxps off|on|conservative|balanced|max|overdrive|riskyWorkingMax`: `on` = `conservative` = level 3; `balanced` = level 6; `max` = level 8; `overdrive` (9) and `riskyWorkingMax` (10) run outside the datasheet timing rule. +- `set radio.rxps level <1-10> [preamble <16|32>]`, `set radio.rxps max|overdrive|riskyWorkingMax preamble <16|32>` +- `set radio.rxps `: manual periods, 1000–30000000 µs, kept across SF/BW changes. + +**Default:** Balanced (level 6, preamble 16) for fresh installs. A saved setting, including `off`, wins. + +**Errors:** a profile that does not fit the active SF/BW is rejected, or, after a radio change, leaves the node in continuous RX with `err=-10001` while the saved choice is kept. Other nonzero `err` values come from the radio driver; after three failed arm attempts the node stays in continuous RX until the setting changes. `set radio` takes effect at reboot; until then the windows of the running modulation stay in use. + +--- + ### Repeater return path (outpath) Remote admin console of a Repeater only; the command needs the requesting client, so it fails on the local serial console. diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 46a3dbb7c98..ee7c08abb83 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -37,6 +37,7 @@ class NodePrefs : public ConfigSerializer { // persisted to file uint32_t gps_interval = 0; // GPS read interval in seconds uint8_t autoadd_config = 0; // bitmask for auto-add contacts config uint8_t rx_boosted_gain = 0; // SX126x RX boosted gain mode (0=power saving, 1=boosted) + RxPowerSavingConfig rxps; uint8_t radio_fem_rxgain = 0; // external LoRa FEM RX gain (LNA) uint8_t radio_fem_txgain = 0; // external LoRa FEM TX gain (low by default) uint8_t _client_repeat = 0; // DEPRECATED -> use repeat.disable_fwd @@ -64,6 +65,11 @@ class NodePrefs : public ConfigSerializer { // persisted to file NodePrefs* _parent; protected: void structure() override { + def("rxps_en", _parent->rxps.enabled); + def("rxps_rx_us", _parent->rxps.rx_us); + def("rxps_sleep_us", _parent->rxps.sleep_us); + def("rxps_level", _parent->rxps.level); + def("rxps_preamble", _parent->rxps.preamble); def("freq", _parent->freq); def("bw", _parent->bw); def("sf", _parent->sf); @@ -84,6 +90,7 @@ class NodePrefs : public ConfigSerializer { // persisted to file } public: RadioPrefs(NodePrefs* parent) : _parent(parent) { } + RxPowerSavingConfig* getRxPowerSavingConfig() override { return &_parent->rxps; } // CommonRadioPrefs interface float getFreq() const override { return _parent->freq; } @@ -211,6 +218,7 @@ class NodePrefs : public ConfigSerializer { // persisted to file , wifi(this) #endif { + rxps.enabled = 1; // fresh installs: Balanced L6/p16; loaded fields override this node_name[0] = 0; default_scope_name[0] = 0; memset(default_scope_key, 0, sizeof(default_scope_key)); @@ -220,6 +228,7 @@ class NodePrefs : public ConfigSerializer { // persisted to file void setRepeatEn(bool en) { repeat.disable_fwd = en ? 0 : 1; } CommonRadioPrefs* getRadioPrefs() { return &radio; } + const CommonRadioPrefs* getRadioPrefs() const { return &radio; } KeyValueStore* getCustom() { return &custom; } bool isDirty() const override { return ConfigSerializer::isDirty() || radio.isDirty() || custom.isDirty(); } diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index db4ac0de5bf..470bf34c5aa 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -67,6 +67,7 @@ class NodePrefs : public ConfigSerializer { float adc_multiplier = 0; char owner_info[120]; uint8_t rx_boosted_gain = 0; // power settings + RxPowerSavingConfig rxps; uint8_t radio_fem_rxgain = 0; // LoRa FEM RX gain setting uint8_t radio_fem_txgain = 0; // LoRa FEM TX gain setting uint8_t path_hash_mode = 0; // which path mode to use when sending @@ -79,6 +80,11 @@ class NodePrefs : public ConfigSerializer { NodePrefs* _parent; protected: void structure() override { + def("rxps_en", _parent->rxps.enabled); + def("rxps_rx_us", _parent->rxps.rx_us); + def("rxps_sleep_us", _parent->rxps.sleep_us); + def("rxps_level", _parent->rxps.level); + def("rxps_preamble", _parent->rxps.preamble); def("freq", _parent->freq); def("bw", _parent->bw); def("sf", _parent->sf); @@ -99,6 +105,7 @@ class NodePrefs : public ConfigSerializer { } public: RadioPrefs(NodePrefs* parent) : _parent(parent) { } + RxPowerSavingConfig* getRxPowerSavingConfig() override { return &_parent->rxps; } // CommonRadioPrefs interface float getFreq() const override { return _parent->freq; } void setFreq(float f) override { _parent->freq = f; markDirty(); } @@ -228,6 +235,7 @@ class NodePrefs : public ConfigSerializer { public: NodePrefs() : ConfigSerializer(), bridge(this), gps(this), radio(this), power(this), repeat(this), room(this), custom(&radio) { + rxps.enabled = 1; // fresh installs: Balanced L6/p16; loaded fields override this node_name[0] = 0; password[0] = 0; guest_password[0] = 0; @@ -236,6 +244,7 @@ class NodePrefs : public ConfigSerializer { } CommonRadioPrefs* getRadioPrefs() { return &radio; } + const CommonRadioPrefs* getRadioPrefs() const { return &radio; } KeyValueStore* getCustom() { return &custom; } bool isDirty() const override { return ConfigSerializer::isDirty() || radio.isDirty() || custom.isDirty(); } diff --git a/src/helpers/CommonRadioPrefs.cpp b/src/helpers/CommonRadioPrefs.cpp index c59f9a3d76e..80dcb145dbd 100644 --- a/src/helpers/CommonRadioPrefs.cpp +++ b/src/helpers/CommonRadioPrefs.cpp @@ -2,6 +2,30 @@ #include "TxtDataHelpers.h" #include "Utils.h" #include "target.h" +#include "radiolib/RXPowerSavingCLI.h" + +static RxPowerSavingControl* rxpsControl() { +#ifdef WRAPPER_CLASS + return &radio_driver; +#else + return nullptr; +#endif +} + +bool CommonRadioPrefs::applyRxPowerSaving(uint8_t sf, float bw) { + _active_sf = sf; + _active_bw = bw; + RxPowerSavingConfig* config = getRxPowerSavingConfig(); + if (!config) return false; + // Temporary radio parameters must not replace the persisted automatic periods. + RxPowerSavingConfig active = *config; + return applyRxPowerSavingConfig(&active, sf, bw, rxpsControl()); +} + +bool CommonRadioPrefs::isRxPowerSavingCalibrationActive() const { + const auto* control = rxpsControl(); + return control && control->isRxPowerSavingCalibrationActive(); +} bool CommonRadioPrefs::getByKey(const char* key, char* value, size_t max_len) { if (strcmp(key, "fem_rxgain") == 0) { @@ -29,6 +53,29 @@ bool CommonRadioPrefs::setByKey(const char* key, const char* value) { } bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + if (strncmp(command, "get radio.rxps", 14) == 0 && + (command[14] == 0 || command[14] == ' ')) { + const char* suffix = command + 14; + while (*suffix == ' ') ++suffix; + if (*suffix) return false; + auto* config = getRxPowerSavingConfig(); + if (!config) { strcpy(reply, "Error: unsupported"); return true; } + const uint8_t sf = _active_sf ? _active_sf : getSpreadFactor(); + const float bw = _active_sf ? _active_bw : getBandwidth(); + RxPowerSavingConfig active = *config; + normalizeRxPowerSavingConfig(&active, sf, bw, + rxPowerSavingCaptureCost(rxpsControl()), rxPowerSavingTransition(rxpsControl())); + RXPowerSavingCLI::get(&active, rxpsControl(), sf, bw, reply, 160); + return true; + } + if (strncmp(command, "set radio.rxps ", 15) == 0) { + auto* config = getRxPowerSavingConfig(); + if (!config) { strcpy(reply, "Error: unsupported"); return true; } + if (RXPowerSavingCLI::set(command + 15, + _active_sf ? _active_sf : getSpreadFactor(), _active_sf ? _active_bw : getBandwidth(), + config, rxpsControl(), reply, 160)) markDirty(); + return true; + } if (strcmp(command, "get radio") == 0) { char freq[16], bw[16]; strcpy(freq, StrHelper::ftoa(getFreq())); @@ -50,7 +97,14 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest setCodingRate(cr); setFreq(freq); setBandwidth(bw); - strcpy(reply, "OK - reboot to apply"); + auto* rxps = getRxPowerSavingConfig(); + const bool timing_ok = !rxps || !rxps->enabled || normalizeRxPowerSavingConfig( + rxps, sf, bw, rxPowerSavingCaptureCost(rxpsControl()), rxPowerSavingTransition(rxpsControl())); + if (!timing_ok && rxpsControl()) { + rxpsControl()->setRxPowerSaving(false, rxps->rx_us, rxps->sleep_us); + rxpsControl()->setRxPowerSavingConfigError(true); + } + strcpy(reply, timing_ok ? "OK - reboot to apply" : "OK - reboot to apply; RXPS continuous: invalid timing"); } else { strcpy(reply, "Error, invalid radio params"); } diff --git a/src/helpers/CommonRadioPrefs.h b/src/helpers/CommonRadioPrefs.h index 0e83ca9dfdb..c53e3d36bc9 100644 --- a/src/helpers/CommonRadioPrefs.h +++ b/src/helpers/CommonRadioPrefs.h @@ -1,9 +1,12 @@ #pragma once #include "ConfigSerializer.h" #include "KeyValueStore.h" +#include "radiolib/RXPowerSaving.h" class CommonRadioPrefs : public ConfigSerializer, public KeyValueStore { bool _is_dirty = false; + uint8_t _active_sf = 0; + float _active_bw = 0; protected: CommonRadioPrefs() { } public: @@ -62,6 +65,10 @@ class CommonRadioPrefs : public ConfigSerializer, public KeyValueStore { virtual uint8_t getFEMTxGain() const = 0; virtual void setFEMTxGain(uint8_t g) = 0; + virtual RxPowerSavingConfig* getRxPowerSavingConfig() { return nullptr; } + bool applyRxPowerSaving(uint8_t sf, float bw); + bool isRxPowerSavingCalibrationActive() const; + bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply); bool setByKey(const char* key, const char* value) override; // for dynamic key/value access diff --git a/test/host/CMakeLists.txt b/test/host/CMakeLists.txt index e8485782485..578bdb8acde 100644 --- a/test/host/CMakeLists.txt +++ b/test/host/CMakeLists.txt @@ -1,4 +1,4 @@ -# Host tests for the RadioLib wrappers and radio drivers. +# Host tests for the RadioLib wrappers, radio drivers and radio preferences. # cmake -S test/host -B .pio/host -DRADIOLIB_DIR= # cmake --build .pio/host && ctest --test-dir .pio/host cmake_minimum_required(VERSION 3.16) @@ -32,3 +32,15 @@ target_compile_options(driver_spi PRIVATE -UNDEBUG -include ${CMAKE_CURRENT_SOUR target_include_directories(driver_spi PRIVATE ../../test/mocks ../../src ../../src/helpers/radiolib) target_link_libraries(driver_spi RadioLib) add_test(NAME driver_spi COMMAND driver_spi) + +# Real NodePrefs (repeater/room/sensor and companion) with CommonRadioPrefs; needs GoogleTest. +find_package(GTest REQUIRED) +add_executable(config_lifecycle config_lifecycle.cpp + ../../src/helpers/CommonRadioPrefs.cpp ../../src/helpers/ConfigSerializer.cpp + ../../src/helpers/DynamicConfigSerializer.cpp ../../src/helpers/TxtDataHelpers.cpp + ../../src/Utils.cpp ../../src/Packet.cpp + ../../src/helpers/radiolib/RXPowerSaving.cpp ../../src/helpers/radiolib/RXPowerSavingCLI.cpp) +target_compile_features(config_lifecycle PRIVATE cxx_std_17) +target_include_directories(config_lifecycle PRIVATE ../../src config_target ../../test/mocks) +target_link_libraries(config_lifecycle GTest::gtest) # the suite has its own main() +add_test(NAME config_lifecycle COMMAND config_lifecycle) diff --git a/test/host/config_lifecycle.cpp b/test/host/config_lifecycle.cpp new file mode 100644 index 00000000000..3c4743cdfc6 --- /dev/null +++ b/test/host/config_lifecycle.cpp @@ -0,0 +1,114 @@ +#include +#include +#include +#include +#define NodePrefs CompanionNodePrefs +#include "../../examples/companion_radio/NodePrefs.h" +#undef NodePrefs +struct TestFileSystem { void mkdir(const char*) {} }; +#define FILESYSTEM TestFileSystem +#include +#include "target.h" +class ReplayStream : public Stream { + const char* _text; + int _pos = 0; + int _len; + +public: + explicit ReplayStream(const char* text) : _text(text), _len(strlen(text)) { } + + int available() override { return _len - _pos; } + int read() override { return _pos < _len ? _text[_pos++] : -1; } + int peek() override { return _pos < _len ? _text[_pos] : -1; } +}; + +class CaptureStream : public Stream { + std::string _text; + + size_t emit(long long value) { + char text[24]; + int length = snprintf(text, sizeof(text), "%lld", value); + return write(reinterpret_cast(text), length); + } + +public: + size_t write(uint8_t value) override { + _text.push_back(static_cast(value)); + return 1; + } + + size_t write(const uint8_t* buffer, size_t size) override { + _text.append(reinterpret_cast(buffer), size); + return size; + } + + size_t print(unsigned char value, int = DEC) override { return emit(value); } + size_t print(int value, int = DEC) override { return emit(value); } + size_t print(unsigned int value, int = DEC) override { return emit(value); } + size_t print(long value, int = DEC) override { return emit(value); } + size_t print(unsigned long value, int = DEC) override { return emit(value); } + size_t print(long long value, int = DEC) override { return emit(value); } + size_t print(unsigned long long value, int = DEC) override { return emit(value); } + + size_t print(double value, int precision = 2) override { + char out[64]; int length=snprintf(out,sizeof(out),"%.*f",precision,value); + return write(reinterpret_cast(out),length); + } + const std::string& text() const { return _text; } +}; + + +template class RadioPrefsTest : public testing::Test { +protected: + T prefs; + void SetUp() override { radio_driver=ConfigTestRadio{};prefs.sf=8;prefs.bw=62.5f; } +}; +using PrefsTypes=testing::Types; +TYPED_TEST_SUITE(RadioPrefsTest,PrefsTypes); +TYPED_TEST(RadioPrefsTest, FreshDefaultsAndSavedOffArePreserved) { + EXPECT_EQ(1,this->prefs.rxps.enabled); EXPECT_EQ(6,this->prefs.rxps.level); EXPECT_EQ(16,this->prefs.rxps.preamble); + ReplayStream input("{radio:{rxps_en:0,rxps_level:8,rxps_preamble:32}}"); + ASSERT_TRUE(this->prefs.loadSerial(input)); EXPECT_EQ(0,this->prefs.rxps.enabled); EXPECT_EQ(8,this->prefs.rxps.level); + ASSERT_TRUE(this->prefs.getRadioPrefs()->applyRxPowerSaving(8,62.5)); EXPECT_FALSE(radio_driver.enabled); +} +TYPED_TEST(RadioPrefsTest, ManualPeriodsRoundTripAndSurviveRetuning) { + auto* p=this->prefs.getRadioPrefs();char reply[160]; + ASSERT_TRUE(p->applyRxPowerSaving(8,62.5)); + ASSERT_TRUE(p->handleCommand("set radio.rxps 12345 23456",0,reply)); ASSERT_EQ(0,this->prefs.rxps.level); + CaptureStream output;ASSERT_TRUE(this->prefs.saveSerial(output)); + TypeParam loaded;ReplayStream input(output.text().c_str());ASSERT_TRUE(loaded.loadSerial(input)); + ASSERT_TRUE(loaded.getRadioPrefs()->applyRxPowerSaving(10,125)); + EXPECT_EQ(12345U,radio_driver.rx);EXPECT_EQ(23456U,radio_driver.sleep); + EXPECT_EQ(1,loaded.rxps.enabled); EXPECT_EQ(0,loaded.rxps.level); +} +TYPED_TEST(RadioPrefsTest, TemporaryRadioFailureRestoresWithoutChangingStoredIntent) { + auto* p=this->prefs.getRadioPrefs();const auto original=this->prefs.rxps; + ASSERT_TRUE(p->applyRxPowerSaving(8,62.5)); auto rx=radio_driver.rx; + EXPECT_FALSE(p->applyRxPowerSaving(6,250)); EXPECT_FALSE(radio_driver.enabled); EXPECT_TRUE(radio_driver.invalid); + EXPECT_EQ(1,this->prefs.rxps.enabled);EXPECT_EQ(original.rx_us,this->prefs.rxps.rx_us); + ASSERT_TRUE(p->applyRxPowerSaving(8,62.5));EXPECT_TRUE(radio_driver.enabled);EXPECT_EQ(rx,radio_driver.rx);EXPECT_FALSE(radio_driver.invalid); +} +TYPED_TEST(RadioPrefsTest, SharedCLIValidatesChangesAndTracksDirtyState) { + auto* p=this->prefs.getRadioPrefs();char reply[160];p->applyRxPowerSaving(8,62.5);this->prefs.clearDirty(); + ASSERT_TRUE(p->handleCommand("set radio.rxps off",0,reply));EXPECT_FALSE(radio_driver.enabled);EXPECT_TRUE(this->prefs.isDirty()); + ASSERT_TRUE(p->handleCommand("set radio.rxps balanced",0,reply));EXPECT_TRUE(radio_driver.enabled); + ASSERT_TRUE(p->handleCommand("get radio.rxps ",0,reply));EXPECT_NE(nullptr,strstr(reply,"desired=on")); + ASSERT_TRUE(p->handleCommand("set radio 869.618,250,6,5",0,reply));EXPECT_FALSE(radio_driver.enabled);EXPECT_TRUE(radio_driver.invalid);EXPECT_EQ(1,this->prefs.rxps.enabled); + EXPECT_EQ(6,this->prefs.sf);EXPECT_NE(nullptr,strstr(reply,"invalid timing")); + ASSERT_TRUE(p->handleCommand("get radio.rxps",0,reply)); + EXPECT_NE(nullptr,strstr(reply,"err=-10001"));EXPECT_EQ(nullptr,strstr(reply,"catch=")); +} +TYPED_TEST(RadioPrefsTest, UnsupportedRXPSDoesNotOverwriteSavedChoice) { + radio_driver.supported=false;char reply[160];this->prefs.rxps.enabled=0; + this->prefs.getRadioPrefs()->handleCommand("set radio.rxps balanced",0,reply); + EXPECT_EQ(0,this->prefs.rxps.enabled);EXPECT_NE(nullptr,strstr(reply,"unsupported")); +} +TEST(CommonPowerPrefs, SavedRebootAndPowerSavingOverrideDefaults) { + NodePrefs p;EXPECT_EQ(1,p.powersaving_enabled);EXPECT_EQ(0,p.reboot_interval); + ReplayStream input("{power:{pwr_sav_en:0,pwr_reboot_int:255}}");ASSERT_TRUE(p.loadSerial(input)); + EXPECT_EQ(0,p.powersaving_enabled);EXPECT_EQ(255,p.reboot_interval); + CaptureStream output;ASSERT_TRUE(p.saveSerial(output)); + NodePrefs restored;ReplayStream saved(output.text().c_str());ASSERT_TRUE(restored.loadSerial(saved)); + EXPECT_EQ(0,restored.powersaving_enabled);EXPECT_EQ(255,restored.reboot_interval); +} +int main(int argc,char** argv){testing::InitGoogleTest(&argc,argv);return RUN_ALL_TESTS();} diff --git a/test/host/config_target/Arduino.h b/test/host/config_target/Arduino.h new file mode 100644 index 00000000000..15295ee6371 --- /dev/null +++ b/test/host/config_target/Arduino.h @@ -0,0 +1,7 @@ +#pragma once +#include_next +#include +#include +#include +#define constrain(x, lo, hi) std::min(std::max((x), (lo)), (hi)) +inline char* ltoa(long value, char* out, int) { std::sprintf(out,"%ld",value);return out; } diff --git a/test/host/config_target/CayenneLPP.h b/test/host/config_target/CayenneLPP.h new file mode 100644 index 00000000000..7c168a5ec75 --- /dev/null +++ b/test/host/config_target/CayenneLPP.h @@ -0,0 +1,2 @@ +#pragma once +class CayenneLPP; diff --git a/test/host/config_target/target.h b/test/host/config_target/target.h new file mode 100644 index 00000000000..28f04494b20 --- /dev/null +++ b/test/host/config_target/target.h @@ -0,0 +1,20 @@ +#pragma once +#include +struct ConfigTestRadio : RxPowerSavingControl { + bool supported=true, enabled=false, invalid=false; + uint32_t rx=0, sleep=0; + bool setRxPowerSaving(bool on,uint32_t r,uint32_t s) override { + if(on && !supported) return false; + enabled=on;rx=r;sleep=s;return true; + } + void setRxPowerSavingConfigError(bool error) override {invalid=error;} + RxPowerSavingStatus getRxPowerSavingStatus() const override { + RxPowerSavingStatus s;s.supported=supported;s.armed=enabled; + s.last_error=invalid ? RX_POWERSAVING_ERR_TIMING : 0;return s; + } + bool setRxBoostedGainMode(bool) {return true;} + void setTxPower(int8_t) {} + uint32_t getCADBusyCount() const {return 0;} +}; +#define WRAPPER_CLASS ConfigTestRadio +inline ConfigTestRadio radio_driver; From dc79ad247fbe390ea6ddc2964c720f559b50366f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Fri, 16 Oct 2026 11:59:38 +0200 Subject: [PATCH 37/44] rxps: apply RXPS through the radio lifecycle of every role Repeater, room server, sensor and companion apply the stored RXPS config after setParams() at boot, after temporary radio params and their revert, and after the companion's CMD_SET_RADIO_PARAMS. The repeater and companion do not count as idle while transmitting or while the noise-floor calibration holds the radio in continuous RX. From PowerSaving-v17 aa159334, 78e134b2 and fd142084. --- examples/companion_radio/MyMesh.cpp | 4 +++- examples/simple_repeater/MyMesh.cpp | 6 +++++- examples/simple_room_server/MyMesh.cpp | 3 +++ examples/simple_sensor/SensorMesh.cpp | 3 +++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f32ea67aecf..3550408f723 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1003,6 +1003,7 @@ void MyMesh::begin() { _store->loadChannels(this); radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); radio_driver.setTxPower(_prefs.tx_power_dbm); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); @@ -1448,6 +1449,7 @@ void MyMesh::handleCmdFrame(size_t len) { savePrefs(); radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf, (uint32_t)cr); @@ -2450,5 +2452,5 @@ bool MyMesh::advert() { // To check if there is pending work bool MyMesh::hasPendingWork() const { - return _mgr->getOutboundTotal() > 0 || dirty_contacts_expiry != 0; + return _mgr->getOutboundTotal() > 0 || dirty_contacts_expiry != 0 || isTransmitting() || _prefs.getRadioPrefs()->isRxPowerSavingCalibrationActive(); } diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 0c33a3e6f3b..10eb5899291 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -979,6 +979,7 @@ void MyMesh::begin(FILESYSTEM *fs) { #endif radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); radio_driver.setTxPower(_prefs.tx_power_dbm); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); @@ -1339,12 +1340,14 @@ void MyMesh::loop() { if (set_radio_at && millisHasNowPassed(set_radio_at)) { // apply pending (temporary) radio params set_radio_at = 0; // clear timer radio_driver.setParams(pending_freq, pending_bw, pending_sf, pending_cr); + _prefs.getRadioPrefs()->applyRxPowerSaving(pending_sf, pending_bw); MESH_DEBUG_PRINTLN("Temp radio params"); } if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { // revert radio params to orig revert_radio_at = 0; // clear timer radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); MESH_DEBUG_PRINTLN("Radio params restored"); } @@ -1365,5 +1368,6 @@ bool MyMesh::hasPendingWork() const { #if defined(WITH_BRIDGE) if (bridge.isRunning()) return true; // bridge needs WiFi radio, can't sleep #endif - return _mgr->getOutboundTotal() > 0 || _mgr->getInboundTotal() > 0; // rxdelay packets wait in the inbound queue + return _mgr->getOutboundTotal() > 0 || _mgr->getInboundTotal() > 0 || // rxdelay packets wait in the inbound queue + isTransmitting() || _prefs.getRadioPrefs()->isRxPowerSavingCalibrationActive(); } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index da4c776c0ea..2d0e6e91906 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -723,6 +723,7 @@ void MyMesh::begin(FILESYSTEM *fs) { } radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); radio_driver.setTxPower(_prefs.tx_power_dbm); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); @@ -1057,12 +1058,14 @@ void MyMesh::loop() { if (set_radio_at && millisHasNowPassed(set_radio_at)) { // apply pending (temporary) radio params set_radio_at = 0; // clear timer radio_driver.setParams(pending_freq, pending_bw, pending_sf, pending_cr); + _prefs.getRadioPrefs()->applyRxPowerSaving(pending_sf, pending_bw); MESH_DEBUG_PRINTLN("Temp radio params"); } if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { // revert radio params to orig revert_radio_at = 0; // clear timer radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); MESH_DEBUG_PRINTLN("Radio params restored"); } diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index b186066810d..ffb1b43b2ed 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -856,6 +856,7 @@ void SensorMesh::begin(FILESYSTEM* fs) { } radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); radio_driver.setTxPower(_prefs.tx_power_dbm); board.attachDynamicPrefs(_prefs.getCustom()); @@ -1018,12 +1019,14 @@ void SensorMesh::loop() { if (set_radio_at && millisHasNowPassed(set_radio_at)) { // apply pending (temporary) radio params set_radio_at = 0; // clear timer radio_driver.setParams(pending_freq, pending_bw, pending_sf, pending_cr); + _prefs.getRadioPrefs()->applyRxPowerSaving(pending_sf, pending_bw); MESH_DEBUG_PRINTLN("Temp radio params"); } if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { // revert radio params to orig revert_radio_at = 0; // clear timer radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); MESH_DEBUG_PRINTLN("Radio params restored"); } From ee616fba1621de38bbaa666e72b1f080e16ffeb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Fri, 16 Oct 2026 12:54:20 +0200 Subject: [PATCH 38/44] radio: external RX LNA off by default; one 'radio.fem.rxgain' path for all roles Fresh installs of every role start with the external LoRa FEM receive LNA off, which saves power; a saved setting wins. A build can default it on with -D RADIO_FEM_RXGAIN=1, replacing the separate _femon/_femoff images of v17. 'get/set radio.fem.rxgain' is handled once in CommonRadioPrefs: - it accepts only on or off; - it answers "Error: unsupported" on boards without a controllable LNA; - it marks the prefs dirty so the value is saved even when it lives in the dynamic store. config_lifecycle covers the defaults, persistence across save/load and unsupported boards. From PowerSaving-v17 9414de85, 0e47bdf8, fde20d37, bbcec955 and d6a75ff4. --- docs/cli_commands.md | 3 +++ examples/companion_radio/MyMesh.cpp | 2 +- examples/simple_repeater/MyMesh.cpp | 2 +- examples/simple_room_server/MyMesh.cpp | 2 +- examples/simple_sensor/SensorMesh.cpp | 2 +- src/helpers/CommonRadioPrefs.cpp | 13 ++++++++++++ src/helpers/CommonRadioPrefs.h | 7 +++++++ test/host/config_lifecycle.cpp | 28 ++++++++++++++++++++++++++ test/host/config_target/target.h | 19 +++++++++++++++++ 9 files changed, 74 insertions(+), 4 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index dd7fb775c25..b1d37c23c80 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -285,9 +285,12 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Parameters:** - `state`: `on`|`off` +**Default:** `off` for fresh installs; a build with `-D RADIO_FEM_RXGAIN=1` defaults to `on`. A saved setting wins. + **Notes:** - This controls the external LoRa FEM receive-path LNA where the board supports it. - This is separate from `radio.rxgain`, which controls the radio chip receive gain mode. +- Boards without a controllable LNA answer `Error: unsupported`. --- diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 3550408f723..fe155b19d8f 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -925,7 +925,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.tx_power_dbm = LORA_TX_POWER; _prefs.gps_enabled = 0; // GPS disabled by default _prefs.gps_interval = 0; // No automatic GPS updates by default - _prefs.radio_fem_rxgain = 1; + _prefs.radio_fem_rxgain = RADIO_FEM_RXGAIN; // external RX LNA off unless the build or a saved value says otherwise #ifdef RADIO_FEM_TXGAIN _prefs.radio_fem_txgain = RADIO_FEM_TXGAIN; // board-specific default for PA #else diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 10eb5899291..60bba452acf 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -934,7 +934,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.rx_boosted_gain = 1; // enabled by default; #endif #endif - _prefs.radio_fem_rxgain = 1; + _prefs.radio_fem_rxgain = RADIO_FEM_RXGAIN; // external RX LNA off unless the build or a saved value says otherwise _prefs.radio_fem_txgain = 0; pending_discover_tag = 0; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 2d0e6e91906..d1e20f76bab 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -681,7 +681,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.rx_boosted_gain = 1; // enabled by default; #endif #endif - _prefs.radio_fem_rxgain = 1; + _prefs.radio_fem_rxgain = RADIO_FEM_RXGAIN; // external RX LNA off unless the build or a saved value says otherwise _prefs.radio_fem_txgain = 0; next_post_idx = 0; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index ffb1b43b2ed..0e010c19d60 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -820,7 +820,7 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise _prefs.gps_enabled = 0; _prefs.gps_interval = 0; _prefs.advert_loc_policy = ADVERT_LOC_PREFS; - _prefs.radio_fem_rxgain = 1; + _prefs.radio_fem_rxgain = RADIO_FEM_RXGAIN; // external RX LNA off unless the build or a saved value says otherwise _prefs.radio_fem_txgain = 0; memset(default_scope.key, 0, sizeof(default_scope.key)); diff --git a/src/helpers/CommonRadioPrefs.cpp b/src/helpers/CommonRadioPrefs.cpp index 80dcb145dbd..944e7686485 100644 --- a/src/helpers/CommonRadioPrefs.cpp +++ b/src/helpers/CommonRadioPrefs.cpp @@ -53,6 +53,19 @@ bool CommonRadioPrefs::setByKey(const char* key, const char* value) { } bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + const bool fem_get = strcmp(command, "get radio.fem.rxgain") == 0; + const bool fem_set = strncmp(command, "set radio.fem.rxgain ", 21) == 0; + if (fem_get || fem_set) { + if (fem_set && strcmp(command + 21, "on") != 0 && strcmp(command + 21, "off") != 0) { + strcpy(reply, "Error: state must be on or off"); + } else if (!board.handleCommand(command, sender_timestamp, reply)) { + strcpy(reply, "Error: unsupported"); + } else if (fem_set && strncmp(reply, "OK", 2) == 0) { + // The board applies the LNA and updates its dynamic preferences. + markDirty(); // also save when the dynamic value lives outside radio.* + } + return true; + } if (strncmp(command, "get radio.rxps", 14) == 0 && (command[14] == 0 || command[14] == ' ')) { const char* suffix = command + 14; diff --git a/src/helpers/CommonRadioPrefs.h b/src/helpers/CommonRadioPrefs.h index c53e3d36bc9..f79e9d07b58 100644 --- a/src/helpers/CommonRadioPrefs.h +++ b/src/helpers/CommonRadioPrefs.h @@ -3,6 +3,13 @@ #include "KeyValueStore.h" #include "radiolib/RXPowerSaving.h" +// Default for the external LoRa FEM receive LNA on boards that have one (fresh +// installs only; 'set radio.fem.rxgain on|off' changes it at runtime). Off saves +// power; a board or env can build with -D RADIO_FEM_RXGAIN=1 to default it on. +#ifndef RADIO_FEM_RXGAIN + #define RADIO_FEM_RXGAIN 0 +#endif + class CommonRadioPrefs : public ConfigSerializer, public KeyValueStore { bool _is_dirty = false; uint8_t _active_sf = 0; diff --git a/test/host/config_lifecycle.cpp b/test/host/config_lifecycle.cpp index 3c4743cdfc6..5478e6fb399 100644 --- a/test/host/config_lifecycle.cpp +++ b/test/host/config_lifecycle.cpp @@ -112,3 +112,31 @@ TEST(CommonPowerPrefs, SavedRebootAndPowerSavingOverrideDefaults) { EXPECT_EQ(0,restored.powersaving_enabled);EXPECT_EQ(255,restored.reboot_interval); } int main(int argc,char** argv){testing::InitGoogleTest(&argc,argv);return RUN_ALL_TESTS();} + +TYPED_TEST(RadioPrefsTest, FEMDefaultsPersistAndDoNotChangeInternalGainOrTX) { + TypeParam prefs;char reply[160]; + prefs.rx_boosted_gain=1;prefs.radio_fem_txgain=1; + board=ConfigTestBoard{};board.attachDynamicPrefs(prefs.getCustom()); + EXPECT_FALSE(board.lna); + auto* radio=prefs.getRadioPrefs(); + ASSERT_TRUE(radio->handleCommand("set radio.fem.rxgain on",0,reply)); + EXPECT_STREQ("OK",reply);EXPECT_TRUE(board.lna);EXPECT_TRUE(prefs.isDirty()); + CaptureStream saved;prefs.saveSerial(saved); + TypeParam restored;ReplayStream input(saved.text().c_str());restored.loadSerial(input); + board.attachDynamicPrefs(restored.getCustom());EXPECT_TRUE(board.lna); + auto* next=restored.getRadioPrefs(); + next->handleCommand("get radio.fem.rxgain",0,reply);EXPECT_STREQ("> on",reply); + next->handleCommand("set radio.fem.rxgain onion",0,reply);EXPECT_NE(strstr(reply,"Error"),nullptr);EXPECT_TRUE(board.lna); + next->handleCommand("set radio.fem.rxgain off",0,reply);EXPECT_FALSE(board.lna); + EXPECT_EQ(1,restored.rx_boosted_gain);EXPECT_EQ(1,restored.radio_fem_txgain); + CaptureStream saved_off;ASSERT_TRUE(restored.saveSerial(saved_off)); + TypeParam off_restored;ReplayStream off_input(saved_off.text().c_str()); + ASSERT_TRUE(off_restored.loadSerial(off_input)); + board.attachDynamicPrefs(off_restored.getCustom());EXPECT_FALSE(board.lna); + EXPECT_EQ(1,off_restored.rx_boosted_gain);EXPECT_EQ(1,off_restored.radio_fem_txgain); + next=off_restored.getRadioPrefs(); + board.supported=false; + next->handleCommand("set radio.fem.rxgain on",0,reply);EXPECT_STREQ("Error: unsupported",reply); + next->handleCommand("get radio.fem.rxgain",0,reply);EXPECT_STREQ("Error: unsupported",reply); + EXPECT_FALSE(board.lna); +} diff --git a/test/host/config_target/target.h b/test/host/config_target/target.h index 28f04494b20..b026aba548c 100644 --- a/test/host/config_target/target.h +++ b/test/host/config_target/target.h @@ -18,3 +18,22 @@ struct ConfigTestRadio : RxPowerSavingControl { }; #define WRAPPER_CLASS ConfigTestRadio inline ConfigTestRadio radio_driver; + +#include +#include +struct ConfigTestBoard { + bool supported = true, lna = false; + KeyValueStore* prefs = nullptr; + void attachDynamicPrefs(KeyValueStore* p) { + prefs=p;char value[8]={};p->getByKey("fem_rxgain",value,sizeof(value));lna=strcmp(value,"1")==0; + } + bool handleCommand(const char* cmd,uint32_t,char* reply) { + if (!supported) return false; + if (strcmp(cmd,"get radio.fem.rxgain")==0) {strcpy(reply,lna ? "> on" : "> off");return true;} + if (strncmp(cmd,"set radio.fem.rxgain ",21)==0) { + lna=strcmp(cmd+21,"on")==0;prefs->setByKey("fem_rxgain",lna ? "1" : "0");strcpy(reply,"OK");return true; + } + return false; + } +}; +inline ConfigTestBoard board; From 1c3c8cbf8b664a873f55d6a67ab9627970302cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Fri, 16 Oct 2026 13:16:53 +0200 Subject: [PATCH 39/44] esp32: build with the IoTThinks power-saving Arduino core 2.0.17 Same package as PowerSaving-v17 (esp32-2.0.17.zip). It enables the power management and tickless-idle configuration that automatic light sleep needs; the stock 2.0.17 core has it disabled. esp32c6_base now extends arduino_base, so C6 boards stay on the pioarduino 3.x framework (without power management). From PowerSaving-v17 c0af1b5c and cb7c4b0f. --- platformio.ini | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 6fa29695e8e..87efd7e0cf1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -57,6 +57,9 @@ build_src_filter = [esp32_base] extends = arduino_base platform = platformio/espressif32@6.11.0 +; PowerSaving Arduino Core 2.0.17 for low power ESP32 boards +; https://github.com/IoTThinks/powersaving-arduino-lib +platform_packages = framework-arduinoespressif32 @ https://github.com/IoTThinks/powersaving-arduino-lib/releases/download/2.0.17/esp32-2.0.17.zip monitor_filters = esp32_exception_decoder extra_scripts = merge-bin.py build_flags = ${arduino_base.build_flags} @@ -72,8 +75,14 @@ lib_deps = ; esp32c6 uses arduino framework 3.x ; WARNING: experimental. May not work as stable as other platforms. [esp32c6_base] -extends = esp32_base +extends = arduino_base platform = https://github.com/pioarduino/platform-espressif32/releases/download/53.03.13-1/platform-espressif32.zip +monitor_filters = esp32_exception_decoder +extra_scripts = merge-bin.py +build_flags = + ${arduino_base.build_flags} + -D ESP32_PLATFORM +build_src_filter = ${arduino_base.build_src_filter} ; ----------------- NRF52 --------------------- From 49ff18751a49a8aa95399e90de16462bf1b2f64c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Fri, 16 Oct 2026 13:50:33 +0200 Subject: [PATCH 40/44] companion: persistent 'powersaving' flag, on by default The companion stores the flag as power.pwr_sav_en, the same key as CommonCLI. It is on for fresh installs; a saved value wins. The 'powersaving', 'powersaving on' and 'powersaving off' commands read and save it. On its own the flag does nothing yet; the ESP32 light sleep that uses it follows. From PowerSaving-v17 594d7709 and 9414de85. --- docs/cli_commands.md | 2 +- examples/companion_radio/MyMesh.cpp | 13 +++++++++++++ examples/companion_radio/NodePrefs.h | 15 ++++++++++++++- test/host/config_lifecycle.cpp | 8 ++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index b1d37c23c80..4a0b4957796 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -448,7 +448,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- -#### View or change this node's power saving flag (Repeater Only) +#### View or change this node's power saving flag (Repeater and companion) **Usage:** - `powersaving` - `powersaving on` diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index fe155b19d8f..c640cd0cc51 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -940,6 +940,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.rx_boosted_gain = 1; // enabled by default #endif #endif + + _prefs.powersaving_enabled = 1; // fresh installs; a saved power.pwr_sav_en overrides it } void MyMesh::begin() { @@ -2183,6 +2185,17 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* } #endif + if (strcmp(command, "powersaving on") == 0 || strcmp(command, "powersaving off") == 0) { + _prefs.powersaving_enabled = strcmp(command, "powersaving on") == 0 ? 1 : 0; + savePrefs(); + strcpy(reply, _prefs.powersaving_enabled ? "on" : "off"); + return true; + } + if (strcmp(command, "powersaving") == 0) { + strcpy(reply, _prefs.powersaving_enabled ? "on" : "off"); + return true; + } + if (strcmp(command, "board") == 0) { strcpy(reply, board.getManufacturerName()); return true; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index ee7c08abb83..dfe5c60e46f 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -49,6 +49,7 @@ class NodePrefs : public ConfigSerializer { // persisted to file char default_scope_name[31]; uint8_t default_scope_key[16]; int8_t tz_offset = 0; + uint8_t powersaving_enabled = 1; // boolean #ifdef ENABLE_WIFI_INTERFACE #ifndef WIFI_SSID #define WIFI_SSID "" @@ -179,6 +180,17 @@ class NodePrefs : public ConfigSerializer { // persisted to file }; CompanionPrefs companion; + class PowerPrefs : public ConfigSerializer { // same keys as CommonCLI + NodePrefs* _parent; + protected: + void structure() override { + def("pwr_sav_en", _parent->powersaving_enabled); + } + public: + PowerPrefs(NodePrefs* parent) : _parent(parent) { } + }; + PowerPrefs power; + DynamicConfigSerializer custom; #ifdef ENABLE_WIFI_INTERFACE @@ -207,13 +219,14 @@ class NodePrefs : public ConfigSerializer { // persisted to file def("gps", gps); def("repeat", repeat); def("comp", companion); + def("power", power); def("custom", custom); #ifdef ENABLE_WIFI_INTERFACE def("wifi", wifi); #endif } public: - NodePrefs() : radio(this), gps(this), companion(this), custom(&radio) + NodePrefs() : radio(this), gps(this), companion(this), power(this), custom(&radio) #ifdef ENABLE_WIFI_INTERFACE , wifi(this) #endif diff --git a/test/host/config_lifecycle.cpp b/test/host/config_lifecycle.cpp index 5478e6fb399..79b2554c2b6 100644 --- a/test/host/config_lifecycle.cpp +++ b/test/host/config_lifecycle.cpp @@ -111,6 +111,14 @@ TEST(CommonPowerPrefs, SavedRebootAndPowerSavingOverrideDefaults) { NodePrefs restored;ReplayStream saved(output.text().c_str());ASSERT_TRUE(restored.loadSerial(saved)); EXPECT_EQ(0,restored.powersaving_enabled);EXPECT_EQ(255,restored.reboot_interval); } +TEST(CompanionPowerPrefs, DefaultOnAndSavedOffRoundTrip) { + CompanionNodePrefs prefs; EXPECT_EQ(1,prefs.powersaving_enabled); + ReplayStream input("{power:{pwr_sav_en:0}}"); ASSERT_TRUE(prefs.loadSerial(input)); + EXPECT_EQ(0,prefs.powersaving_enabled); + CaptureStream output; ASSERT_TRUE(prefs.saveSerial(output)); + CompanionNodePrefs restored; ReplayStream saved(output.text().c_str()); + ASSERT_TRUE(restored.loadSerial(saved)); EXPECT_EQ(0,restored.powersaving_enabled); +} int main(int argc,char** argv){testing::InitGoogleTest(&argc,argv);return RUN_ALL_TESTS();} TYPED_TEST(RadioPrefsTest, FEMDefaultsPersistAndDoNotChangeInternalGainOrTX) { From 180881111bd46a0c98fe5e9c7b56b746c9338766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Fri, 16 Oct 2026 14:29:10 +0200 Subject: [PATCH 41/44] companion(esp32): automatic light sleep driven by the powersaving flag esp_pm scales the CPU between 80 and 40 MHz. When powersaving is on, it also enables automatic light sleep, and the BLE controller sleeps between connection events. The idle loop yields to the idle task only when no work is pending and no interface has a frame queued (isReadBusy/isWriteBusy). Changing the flag from the CLI reconfigures power management without a reboot. Light sleep stays off, keeping frequency scaling only, where a wake-up source would be missed: - USB CDC; - hardware UART (SERIAL_RX); - Ethernet (INT pin); - ESP-NOW (the Wi-Fi receiver is the mesh radio); - a configured Wi-Fi network. POWERSAVING_DEBUG_PRINT/LN log these decisions when built with -D POWERSAVING_DEBUG=1. From PowerSaving-v17 594d7709, d42f4240, a3b9ad91, 9c728e6b, 4027acc7 and 1e888cb3. --- docs/cli_commands.md | 2 + examples/companion_radio/main.cpp | 63 +++++++++++++++++++++++++++++++ src/MeshCore.h | 9 +++++ 3 files changed, 74 insertions(+) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 4a0b4957796..c7267e4f6e2 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -462,6 +462,8 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Note:** When enabled, device enters sleep mode between radio transmissions +**Companion:** on ESP32 the flag switches automatic light sleep at once (no reboot). Light sleep stays off on USB-serial, hardware-serial, Ethernet and ESP-NOW builds, and while a WiFi network is configured; those builds still scale the CPU clock (80/40 MHz). ESP32-C6 uses the upstream Arduino 3.x core without power management, so it gets neither. nRF52 companions sleep between events regardless of the flag. + --- ### Routing diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 839d4958336..1af3b281d88 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -2,6 +2,11 @@ #include #include "MyMesh.h" +#ifdef ESP32_PLATFORM +#include "esp_pm.h" +#include "esp_bt.h" +#endif + // Believe it or not, this std C function is busted on some platforms! static uint32_t _atoi(const char* sp) { uint32_t n = 0; @@ -132,6 +137,44 @@ void halt() { bool wifi_enabled = false; // set at boot from prefs; false also when the effective SSID is blank #endif +#if defined(ESP32_PLATFORM) +// ESP32 power management: frequency scaling, plus automatic light sleep while +// powersaving is on and nothing needs the CPU clock running. +static void applyCompanionPowerSaving() { + bool light_sleep = the_mesh.getNodePrefs()->powersaving_enabled != 0; +#ifndef WRAPPER_CLASS + light_sleep = false; // ESP-NOW: the Wi-Fi receiver is the mesh radio +#endif +#if (defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT) || defined(ENABLE_USB_INTERFACE) + light_sleep = false; // USB CDC serial does not survive automatic light sleep +#endif +#if defined(SERIAL_RX) || defined(ETHERNET_ENABLED) + light_sleep = false; // UART RX and the Ethernet INT pin cannot wake the CPU +#endif +#ifdef ENABLE_WIFI_INTERFACE + if (wifi_enabled && wifi_ssid[0]) light_sleep = false; // WiFi is up: keep the modem awake +#endif + +#if CONFIG_IDF_TARGET_ESP32C3 + esp_pm_config_esp32c3_t pm_config; +#elif CONFIG_IDF_TARGET_ESP32S3 + esp_pm_config_esp32s3_t pm_config; +#elif CONFIG_IDF_TARGET_ESP32 + esp_pm_config_esp32_t pm_config; +#else + esp_pm_config_t pm_config; +#endif + pm_config = { .max_freq_mhz = 80, .min_freq_mhz = 40, .light_sleep_enable = light_sleep }; + + esp_err_t err = esp_pm_configure(&pm_config); + if (err == ESP_OK) { + POWERSAVING_DEBUG_PRINTLN("Power management configured, light sleep %s", light_sleep ? "on" : "off"); + } else { + Serial.printf("Power Management failed to configure: %d\r\n", err); + } +} +#endif + void setup() { Serial.begin(115200); board.begin(); @@ -287,6 +330,13 @@ void setup() { #endif board.onBootComplete(); + +#if defined(ESP32_PLATFORM) + #if defined(BLE_PIN_CODE) && !CONFIG_IDF_TARGET_ESP32C6 + esp_bt_sleep_enable(); // let the BLE controller sleep between connection events + #endif + applyCompanionPowerSaving(); +#endif } void loop() { @@ -305,9 +355,22 @@ void loop() { if (!the_mesh.hasPendingWork()) { #if defined(NRF52_PLATFORM) board.sleep(0); // nrf ignores seconds param, sleeps whenever possible +#elif defined(ESP32_PLATFORM) + if (the_mesh.getNodePrefs()->powersaving_enabled && + !interface_manager.isReadBusy() && !interface_manager.isWriteBusy()) { + vTaskDelay(pdMS_TO_TICKS(10)); // yield to the idle task: lower clock, or light sleep when enabled + } #endif } +#if defined(ESP32_PLATFORM) + static uint8_t applied_powersaving = the_mesh.getNodePrefs()->powersaving_enabled; + if (the_mesh.getNodePrefs()->powersaving_enabled != applied_powersaving) { // changed via CLI + applied_powersaving = the_mesh.getNodePrefs()->powersaving_enabled; + applyCompanionPowerSaving(); + } +#endif + #ifdef ENABLE_WIFI_INTERFACE if (wifi_enabled) { // RP2040 has no WiFi event callbacks, so poll the link state instead diff --git a/src/MeshCore.h b/src/MeshCore.h index d454bf2c178..cd3c68e078f 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -37,6 +37,15 @@ #define BRIDGE_DEBUG_PRINTLN(...) {} #endif +#if POWERSAVING_DEBUG && ARDUINO + #include + #define POWERSAVING_DEBUG_PRINT(F, ...) Serial.printf("POWERSAVING: " F, ##__VA_ARGS__) + #define POWERSAVING_DEBUG_PRINTLN(F, ...) Serial.printf("POWERSAVING: " F "\n", ##__VA_ARGS__) +#else + #define POWERSAVING_DEBUG_PRINT(...) {} + #define POWERSAVING_DEBUG_PRINTLN(...) {} +#endif + namespace mesh { #define BD_STARTUP_NORMAL 0 // getStartupReason() codes From 0060ffd3d16d7d68da46699b6af0b525784b36c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Fri, 16 Oct 2026 15:20:49 +0200 Subject: [PATCH 42/44] room server: MCU sleep between radio and service deadlines With powersaving on, the room server sleeps when it has no pending work. - Pending work: an outbound or delayed inbound packet, a TX, the noise-floor calibration, a CLI command or serial input. - Sleep length: the time to the next deadline (adverts, temporary radio params and their revert, contact save, push to logged-in clients), at most IDLE_SLEEP_MAX_MILLIS = 30 s, the repeater's and v17's limit. A LoRa packet or the user button wakes it earlier. Shared helpers: - IdleSleepBudget computes the sleep length; tested in test_idle_sleep. - sleepRoleIfIdle() (RoleSleep.h) keeps the node awake during OTA, with Ethernet, with active USB CDC, with GPS on, for the first 120 s after boot, and while a frame is being received. It observes the wrapper without starting CAD, so duty-cycled RX is not disturbed. - MainBoard gains sleepMillis(); ESP32Board implements it as a light sleep with a microsecond timer. From PowerSaving-v17 be6f3599 and 61048f58. --- docs/cli_commands.md | 2 +- examples/simple_room_server/MyMesh.cpp | 15 ++++++++++++++ examples/simple_room_server/MyMesh.h | 2 ++ examples/simple_room_server/main.cpp | 4 ++++ src/MeshCore.h | 1 + src/helpers/ESP32Board.h | 9 ++++++--- src/helpers/IdleSleep.h | 22 +++++++++++++++++++++ src/helpers/RoleSleep.h | 25 ++++++++++++++++++++++++ src/helpers/radiolib/RadioLibWrappers.h | 2 ++ test/test_idle_sleep/test_idle_sleep.cpp | 22 +++++++++++++++++++++ 10 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 src/helpers/IdleSleep.h create mode 100644 src/helpers/RoleSleep.h create mode 100644 test/test_idle_sleep/test_idle_sleep.cpp diff --git a/docs/cli_commands.md b/docs/cli_commands.md index c7267e4f6e2..83b739a4ad6 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -448,7 +448,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- -#### View or change this node's power saving flag (Repeater and companion) +#### View or change this node's power saving flag (Repeater, room server and companion) **Usage:** - `powersaving` - `powersaving on` diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index d1e20f76bab..7072e5636a2 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1,4 +1,5 @@ #include "MyMesh.h" +#include #define REPLY_DELAY_MILLIS 1500 #define PUSH_NOTIFY_DELAY_MILLIS 2000 @@ -1082,3 +1083,17 @@ void MyMesh::loop() { uptime_millis += now - last_millis; last_millis = now; } + +bool MyMesh::hasPendingWork() const { + return _mgr->getOutboundTotal() > 0 || _mgr->getInboundTotal() > 0 || // rxdelay packets wait in the inbound queue + isTransmitting() || _prefs.getRadioPrefs()->isRxPowerSavingCalibrationActive(); +} + +uint32_t MyMesh::getSleepBudgetMillis() { + IdleSleepBudget budget(millis()); + budget.until(next_local_advert); budget.until(next_flood_advert); + budget.until(set_radio_at); budget.until(revert_radio_at); + budget.until(dirty_contacts_expiry); + if (acl.getNumClients() > 0) budget.until(next_push); + return budget.millis(); +} diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 4dee8bdbf40..f9cfc250796 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -229,4 +229,6 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void clearStats() override; void handleCommand(uint32_t sender_timestamp, char* command, char* reply); void loop(); + bool hasPendingWork() const; + uint32_t getSleepBudgetMillis(); }; diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index eb6438f3e7a..5eb5dbd1237 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -4,6 +4,7 @@ static PeriodicReboot periodic_reboot; #include #include "MyMesh.h" +#include #ifdef ETHERNET_ENABLED #define ETHERNET_CLI_BANNER "MeshCore Room Server CLI" @@ -172,5 +173,8 @@ void loop() { the_mesh.isTransmitting(), board.isOTAUpdateActive())) { board.reboot(); } + sleepRoleIfIdle(the_mesh.getNodePrefs()->powersaving_enabled, + the_mesh.hasPendingWork(), command[0] != 0, + the_mesh.getSleepBudgetMillis()); } diff --git a/src/MeshCore.h b/src/MeshCore.h index cd3c68e078f..8c9c66802a5 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -73,6 +73,7 @@ class MainBoard { virtual void onBootComplete() { /* no op */ } virtual uint32_t getIRQGpio() { return -1; } // not supported. Returns DIO1 (SX1262) and DIO0 (SX127x) virtual void sleep(uint32_t secs) { /* no op */ } + virtual void sleepMillis(uint32_t ms) { if (ms) sleep(0); } virtual uint32_t getGpio() { return 0; } virtual void setGpio(uint32_t values) {} virtual uint8_t getStartupReason() const = 0; diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 90b3e111d19..f524ef3e072 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -80,7 +80,10 @@ class ESP32Board : public mesh::MainBoard { #endif } - void sleep(uint32_t secs) override { + void sleep(uint32_t secs) override { sleepMicros(secs * 1000000ULL); } + void sleepMillis(uint32_t ms) override { if (ms) sleepMicros(ms * 1000ULL); } + + void sleepMicros(uint64_t micros) { // Skip if not allow to sleep if (button_sleep_inhibited && (uint32_t)(millis() - button_woke_at) >= 30000) { button_sleep_inhibited = false; @@ -99,8 +102,8 @@ class ESP32Board : public mesh::MainBoard { gpio_num_t wakeupPin = (gpio_num_t)irq_gpio; // Configure timer wakeup - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000ULL); // Wake up periodically to do scheduled jobs + if (micros > 0) { + esp_sleep_enable_timer_wakeup(micros); // Wake up periodically to do scheduled jobs } // Disable CPU interrupt servicing diff --git a/src/helpers/IdleSleep.h b/src/helpers/IdleSleep.h new file mode 100644 index 00000000000..6e54782091b --- /dev/null +++ b/src/helpers/IdleSleep.h @@ -0,0 +1,22 @@ +#pragma once +#include + +// Longest single ESP32 light sleep of the Room Server and Sensor, as the Repeater's +// sleep(30) and PowerSaving-v17. A LoRa packet or the user button wakes earlier. +#ifndef IDLE_SLEEP_MAX_MILLIS + #define IDLE_SLEEP_MAX_MILLIS 30000 +#endif + +// Sleep at most IDLE_SLEEP_MAX_MILLIS, shortened to the next role deadline. +class IdleSleepBudget { + uint32_t _now, _budget; +public: + explicit IdleSleepBudget(uint32_t now, uint32_t max_ms = IDLE_SLEEP_MAX_MILLIS) : _now(now), _budget(max_ms) {} + void until(uint32_t deadline) { + if (!deadline) return; + int32_t remaining = (int32_t)(deadline - _now); + if (remaining <= 0) _budget = 0; + else if ((uint32_t)remaining < _budget) _budget = remaining; + } + uint32_t millis() const { return _budget; } +}; diff --git a/src/helpers/RoleSleep.h b/src/helpers/RoleSleep.h new file mode 100644 index 00000000000..9cdd8f5993f --- /dev/null +++ b/src/helpers/RoleSleep.h @@ -0,0 +1,25 @@ +#pragma once +// Include after target.h: these are the role's board, radio and sensor objects. +inline void sleepRoleIfIdle(bool enabled, bool pending, bool command_pending, uint32_t budget_ms) { + if (!enabled || pending || command_pending || !budget_ms || + board.isOTAUpdateActive() || Serial.available()) return; +#ifdef ETHERNET_ENABLED + return; // the Ethernet CLI/task must remain serviced +#endif +#if defined(ESP32_PLATFORM) + if (millis() < 120000) return; // retain the serial setup window from v17 +#if defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT + if (Serial) return; // active USB CDC cannot tolerate explicit ESP32 light sleep +#endif + auto* location = sensors.getLocationProvider(); + if (location && location->isEnabled()) return; // keep GPS UART reception live +#endif +#ifdef WRAPPER_CLASS + // Observation only: isReceiving() would start CAD and disturb duty-cycle RX. + if (radio_driver.hasPendingReceive()) return; +#else + if (radio_driver.isReceiving()) return; +#endif + Serial.flush(); + board.sleepMillis(budget_ms); +} diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 666375c6973..23fa38aee7f 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -100,6 +100,8 @@ class RadioLibWrapper : public mesh::Radio, public RxPowerSavingControl { RxPowerSavingStatus getRxPowerSavingStatus() const override; bool isRxPowerSavingCalibrationActive() const override { return _nf_calib_active; } + bool hasPendingReceive() { return isPacketPendingOrReceiving(); } + bool isReceiving() override { if (isReceivingPacket()) return true; diff --git a/test/test_idle_sleep/test_idle_sleep.cpp b/test/test_idle_sleep/test_idle_sleep.cpp new file mode 100644 index 00000000000..e5968447c60 --- /dev/null +++ b/test/test_idle_sleep/test_idle_sleep.cpp @@ -0,0 +1,22 @@ +#include +#include +TEST(IdleSleep, ShortensToEarliestDeadline) { + IdleSleepBudget budget(1000); + budget.until(5000);budget.until(1006);budget.until(1009); + EXPECT_EQ(6U,budget.millis()); +} +TEST(IdleSleep, ReadyWorkPreventsSleepingAndDisabledTimerDoesNot) { + IdleSleepBudget budget(1000);budget.until(0);EXPECT_EQ((uint32_t)IDLE_SLEEP_MAX_MILLIS,budget.millis()); + budget.until(1000);EXPECT_EQ(0U,budget.millis()); + IdleSleepBudget overdue(1000);overdue.until(999);EXPECT_EQ(0U,overdue.millis()); +} +TEST(IdleSleep, TimerWrapKeepsFutureAndPastDeadlinesSeparate) { + IdleSleepBudget before(0xfffffffeU);before.until(3);EXPECT_EQ(5U,before.millis()); + IdleSleepBudget after(2);after.until(0xfffffffeU);EXPECT_EQ(0U,after.millis()); +} +TEST(IdleSleep, FarDeadlineIsCappedAtTheRoleMaximum) { + IdleSleepBudget budget(1000);budget.until(1000+10*IDLE_SLEEP_MAX_MILLIS); + EXPECT_EQ((uint32_t)IDLE_SLEEP_MAX_MILLIS,budget.millis()); + IdleSleepBudget sixty(5000,60000);sixty.until(5000+45000);EXPECT_EQ(45000U,sixty.millis()); +} +int main(int argc,char**argv) {testing::InitGoogleTest(&argc,argv);return RUN_ALL_TESTS();} From 0d7e8b1fe374b27a213488b38ea67c21eaaa122e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Fri, 16 Oct 2026 15:51:23 +0200 Subject: [PATCH 43/44] sensor: MCU sleep between sampling, alerts and radio deadlines With powersaving on, the sensor sleeps like the room server, through sleepRoleIfIdle(). The sleep is shortened to the next sensor read and to the send deadline of every queued alert. It does not sleep when a read is already due. Delayed inbound packets, TX and the noise-floor calibration count as pending work. From PowerSaving-v17 e125d375 and 1ce07cd8. --- docs/cli_commands.md | 2 +- examples/simple_sensor/SensorMesh.cpp | 18 ++++++++++++++++++ examples/simple_sensor/SensorMesh.h | 2 ++ examples/simple_sensor/main.cpp | 4 ++++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 83b739a4ad6..94cbf290bb2 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -448,7 +448,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- -#### View or change this node's power saving flag (Repeater, room server and companion) +#### View or change this node's power saving flag (Repeater, room server, sensor and companion) **Usage:** - `powersaving` - `powersaving on` diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 0e010c19d60..5735dd2e8a7 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -1,4 +1,5 @@ #include "SensorMesh.h" +#include #include #include @@ -1117,3 +1118,20 @@ void SensorMesh::loop() { dirty_contacts_expiry = 0; } } + +bool SensorMesh::hasPendingWork() const { + return _mgr->getOutboundTotal() > 0 || _mgr->getInboundTotal() > 0 || // rxdelay packets wait in the inbound queue + isTransmitting() || _prefs.getRadioPrefs()->isRxPowerSavingCalibrationActive(); +} + +uint32_t SensorMesh::getSleepBudgetMillis() { + IdleSleepBudget budget(millis()); + budget.until(next_local_advert); budget.until(next_flood_advert); + budget.until(set_radio_at); budget.until(revert_radio_at); + budget.until(dirty_contacts_expiry); + for (int i = 0; i < num_alert_tasks; i++) budget.until(alert_tasks[i]->send_expiry); + uint32_t now_secs = getRTCClock()->getCurrentTime(); + if (now_secs >= last_read_time + SENSOR_READ_INTERVAL_SECS) return 0; + budget.until(millis() + (last_read_time + SENSOR_READ_INTERVAL_SECS - now_secs) * 1000UL); // next sensor read + return budget.millis(); +} diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 590ca0c7657..e77c9a3f63b 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -51,6 +51,8 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); void begin(FILESYSTEM* fs); void loop(); + bool hasPendingWork() const; + uint32_t getSleepBudgetMillis(); void handleCommand(ClientInfo* from, uint32_t sender_timestamp, char* command, char* reply); // CommonCLI callbacks diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index a8dfdd1a565..d35e2378b5f 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -1,6 +1,7 @@ #include static PeriodicReboot periodic_reboot; #include "SensorMesh.h" +#include #ifdef DISPLAY_CLASS #include "UITask.h" @@ -170,5 +171,8 @@ void loop() { the_mesh.isTransmitting(), board.isOTAUpdateActive())) { board.reboot(); } + sleepRoleIfIdle(the_mesh.getNodePrefs()->powersaving_enabled, + the_mesh.hasPendingWork(), command[0] != 0, + the_mesh.getSleepBudgetMillis()); } From 7985978ff9d521edabcb830273b858cb09794a4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Doma=C5=84ski?= Date: Fri, 16 Oct 2026 17:48:02 +0200 Subject: [PATCH 44/44] ui(st7735): fix freeze after the display turns off On Heltec Tracker V2 and Wireless Tracker the screen and the LoRa radio share one SPI bus. Turning the screen off also stopped that bus, so the radio hung on its next access and the whole device froze (USB, screen and button) until reset. Turning the screen off now releases only the screen's own lines and keeps the bus running for the radio. --- src/helpers/ui/ST7735Display.cpp | 49 +++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/src/helpers/ui/ST7735Display.cpp b/src/helpers/ui/ST7735Display.cpp index 5cde200acb8..774d56cfed2 100644 --- a/src/helpers/ui/ST7735Display.cpp +++ b/src/helpers/ui/ST7735Display.cpp @@ -428,15 +428,39 @@ ColorVal UIColor::popup_bkg = ST77XX_BLACK; ColorVal UIColor::popup_txt = ST77XX_WHITE; ColorVal UIColor::corp_blue = ST77XX_BLUE; -// Attaches SCK/MOSI to the SPI peripheral. pinMode() on them would detach it instead: -// ESP32 routes the pin back to plain GPIO, nRF52 drops the SCK input buffer SPIM needs. -static void beginSpi() { +#ifdef NRF52_PLATFORM + #include +#endif + +// Move SCK/MOSI between the SPI peripheral and plain inputs without touching the bus. +// On Tracker V2 and Wireless Tracker the LoRa radio shares this SPI peripheral (HSPI), and +// SPIClass::end() gates its clock, so the radio's next transfer would never complete. +// pinMode() alone cannot restore them: ESP32 routes the pin back to plain GPIO, and nRF52 +// drops the SCK input buffer that SPIM needs. +static void attachSpiPins() { +#ifdef ESP_PLATFORM + spiAttachSCK(_spi->bus(), PIN_TFT_SCL); + spiAttachMOSI(_spi->bus(), PIN_TFT_SDA); +#elif defined(NRF52_PLATFORM) + // same configuration as SPIClass::begin() + nrf_gpio_cfg(g_ADigitalPinMap[PIN_TFT_SCL], NRF_GPIO_PIN_DIR_OUTPUT, NRF_GPIO_PIN_INPUT_CONNECT, + NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_H0H1, NRF_GPIO_PIN_NOSENSE); + nrf_gpio_cfg(g_ADigitalPinMap[PIN_TFT_SDA], NRF_GPIO_PIN_DIR_OUTPUT, NRF_GPIO_PIN_INPUT_DISCONNECT, + NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_H0H1, NRF_GPIO_PIN_NOSENSE); +#else + pinMode(PIN_TFT_SCL, OUTPUT); + pinMode(PIN_TFT_SDA, OUTPUT); +#endif +} + +static void detachSpiPins() { #ifdef ESP_PLATFORM - _spi->begin(PIN_TFT_SCL, -1 /* _miso */, PIN_TFT_SDA /* _mosi */, -1); + spiDetachSCK(_spi->bus(), PIN_TFT_SCL); + spiDetachMOSI(_spi->bus(), PIN_TFT_SDA); #else - _spi->begin(); + pinMode(PIN_TFT_SCL, INPUT); + pinMode(PIN_TFT_SDA, INPUT); #endif - _spi->setClockDivider(SPI_CLOCK_DIV2); } bool ST7735Display::begin() { @@ -464,7 +488,12 @@ bool ST7735Display::begin() { pinMode(PIN_TFT_DC, OUTPUT); pinMode(PIN_TFT_LEDA_CTL, OUTPUT); - beginSpi(); +#ifdef ESP_PLATFORM + _spi->begin(PIN_TFT_SCL, -1 /* _miso */, PIN_TFT_SDA /* _mosi */, -1); +#else + _spi->begin(); +#endif + _spi->setClockDivider(SPI_CLOCK_DIV2); _height = 80; _width = 160; @@ -524,7 +553,7 @@ void ST7735Display::turnOn() { pinMode(PIN_TFT_DC, OUTPUT); pinMode(PIN_TFT_RST, OUTPUT); pinMode(PIN_TFT_LEDA_CTL, OUTPUT); - beginSpi(); + attachSpiPins(); _resetAndInit(); sendCommand(ST77XX_DISPON); @@ -544,11 +573,9 @@ void ST7735Display::turnOff() { digitalWrite(PIN_TFT_LEDA_CTL, !PIN_TFT_LEDA_CTL_ACTIVE); // Stop the signal pins from powering the display after its supply is cut. - _spi->end(); // release SCK/MOSI; turnOn() attaches them again pinMode(PIN_TFT_CS, INPUT); pinMode(PIN_TFT_DC, INPUT); - pinMode(PIN_TFT_SDA, INPUT); - pinMode(PIN_TFT_SCL, INPUT); + detachSpiPins(); pinMode(PIN_TFT_RST, INPUT); pinMode(PIN_TFT_LEDA_CTL, INPUT);