From 448d1b2239859ced5e44047bdc89717122ac626e Mon Sep 17 00:00:00 2001 From: Alexander Wunschik Date: Fri, 18 Sep 2026 16:59:26 +0200 Subject: [PATCH 01/34] docs: fix frame format tables --- docs/kiss_modem_protocol.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/kiss_modem_protocol.md b/docs/kiss_modem_protocol.md index 3f4dbf9c4..7ff487868 100644 --- a/docs/kiss_modem_protocol.md +++ b/docs/kiss_modem_protocol.md @@ -18,10 +18,10 @@ Standard KISS framing per the KA9Q/K3MC specification. | `0xDD` | TFESC | Escaped FESC (FESC + TFESC = 0xDB) | ``` -┌──────┬───────────┬──────────────┬──────┐ -│ FEND │ Type Byte │ Data (escaped)│ FEND │ -│ 0xC0 │ 1 byte │ 0-510 bytes │ 0xC0 │ -└──────┴───────────┴──────────────┴──────┘ +┌──────┬───────────┬────────────────┬──────┐ +│ FEND │ Type Byte │ Data (escaped) │ FEND │ +│ 0xC0 │ 1 byte │ 0-510 bytes │ 0xC0 │ +└──────┴───────────┴────────────────┴──────┘ ``` ### Type Byte @@ -82,10 +82,10 @@ MeshCore-specific functionality uses the standard KISS SetHardware command. The ### Frame Format ``` -┌──────┬──────┬─────────────┬──────────────┬──────┐ -│ FEND │ 0x06 │ Sub-command │ Data (escaped)│ FEND │ -│ 0xC0 │ │ 1 byte │ variable │ 0xC0 │ -└──────┴──────┴─────────────┴──────────────┴──────┘ +┌──────┬──────┬─────────────┬────────────────┬──────┐ +│ FEND │ 0x06 │ Sub-command │ Data (escaped) │ FEND │ +│ 0xC0 │ │ 1 byte │ variable │ 0xC0 │ +└──────┴──────┴─────────────┴────────────────┴──────┘ ``` ### Request Sub-commands (Host to TNC) From a11009f2dffa1bbbe428a970049d7b87882cff38 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Wed, 30 Sep 2026 14:52:58 +1300 Subject: [PATCH 02/34] add radio preset docs --- docs/radio_presets.md | 66 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 docs/radio_presets.md diff --git a/docs/radio_presets.md b/docs/radio_presets.md new file mode 100644 index 000000000..ea3b07e51 --- /dev/null +++ b/docs/radio_presets.md @@ -0,0 +1,66 @@ +# Radio Presets + +The radio presets are currently stored on the MeshCore App API server. + +This allows us to update the presets without releasing new application updates. + +The presets can be fetched from [https://api.meshcore.nz/api/v1/config](https://api.meshcore.nz/api/v1/config) + +If you'd like a new radio preset to be added, this will need to be brought up for discussion in the [MeshCore Discord](https://meshcore.gg) server, or via the [issues section](https://github.com/meshcore-dev/MeshCore/issues) in our GitHub repo. + +Before a new preset will be considered for being added, local radio regulations must be researched by the person suggesting the preset, and investigation of the frequencies with an SDR is advised to ensure the least interference to provide a decent user experience. + +Once a preset is added, it's unlikely to be changed. Adding too many presets can lead to user frustration, as new users won't know which preset to select during setup. + +At this time, presets provided by the MeshCore App API are just suggestions, but you are free to configure any radio settings through the companion applications, provided they comply with local laws/regulations. + +> Note: We are planning to implement deep linking such as meshcore:// URLs, and QR codes so local mesh networks can easily distribute their own settings, without relying on the internet, or all the companion applications to provide all the settings. + +## JSON Response + +See the example JSON response below, which shows the expected format provided by the API server. + +Your application should be programmed to expect any of the JSON keys to be missing from the response, as the format could change at any time without any notification. + +Please configure an appropriate User Agent header string when sending your request to the server. We should be able to reach out to you if needed. + +> Note: Some presets contain an optional `network_settings` section which defines the `path_hash_size` to use for this mesh network. + +```json +{ + "config": { + "suggested_radio_settings": { + "info_message": "These presets are suggested by the community.", + "entries": [ + { + "title": "Australia (Narrow)", + "description": "916.575MHz / SF7 / BW62.5 / CR7", + "frequency": "916.575", + "spreading_factor": "7", + "bandwidth": "62.5", + "coding_rate": "7" + }, + { + "title": "New Zealand (Narrow)", + "description": "917.375MHz / SF7 / BW62.5 / CR5 / 2B", + "frequency": "917.375", + "spreading_factor": "7", + "bandwidth": "62.5", + "coding_rate": "5", + "network_settings": { + "path_hash_size": 2 + } + }, + { + "title": "Portugal 868", + "description": "869.618MHz / SF7 / BW62.5 / CR6", + "frequency": "869.618", + "spreading_factor": "7", + "bandwidth": "62.5", + "coding_rate": "6" + } + ] + } + } +} +``` \ No newline at end of file From 597096836f016f1225f0b9a73c8024ce32f9887c Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sat, 17 Oct 2026 08:51:32 +0700 Subject: [PATCH 03/34] Custom ld to keep time for NRF52 --- boards/nrf52840_s140_v6.ld | 15 +++++++++++++-- boards/nrf52840_s140_v6_extrafs.ld | 15 +++++++++++++-- boards/nrf52840_s140_v7.ld | 15 +++++++++++++-- boards/nrf52840_s140_v7_extrafs.ld | 15 +++++++++++++-- 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/boards/nrf52840_s140_v6.ld b/boards/nrf52840_s140_v6.ld index 6dad975b0..d0c7d1dc8 100644 --- a/boards/nrf52840_s140_v6.ld +++ b/boards/nrf52840_s140_v6.ld @@ -7,6 +7,9 @@ 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 @@ -14,11 +17,19 @@ MEMORY * - 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 : { @@ -33,6 +44,6 @@ SECTIONS KEEP(*(.fs_data)) PROVIDE(__stop_fs_data = .); } > RAM -} INSERT AFTER .data; +} INCLUDE "nrf52_common.ld" diff --git a/boards/nrf52840_s140_v6_extrafs.ld b/boards/nrf52840_s140_v6_extrafs.ld index 352610679..bd4547473 100644 --- a/boards/nrf52840_s140_v6_extrafs.ld +++ b/boards/nrf52840_s140_v6_extrafs.ld @@ -7,6 +7,9 @@ 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 @@ -14,11 +17,19 @@ MEMORY * - 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 : { @@ -33,6 +44,6 @@ SECTIONS 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 6aaeb4034..2333238f2 100644 --- a/boards/nrf52840_s140_v7.ld +++ b/boards/nrf52840_s140_v7.ld @@ -7,6 +7,9 @@ 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 @@ -14,11 +17,19 @@ MEMORY * - 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 : { @@ -33,6 +44,6 @@ SECTIONS 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 5956183aa..ef6ebba15 100644 --- a/boards/nrf52840_s140_v7_extrafs.ld +++ b/boards/nrf52840_s140_v7_extrafs.ld @@ -7,6 +7,9 @@ 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 @@ -14,11 +17,19 @@ MEMORY * - 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 : { @@ -33,6 +44,6 @@ SECTIONS KEEP(*(.fs_data)) PROVIDE(__stop_fs_data = .); } > RAM -} INSERT AFTER .data; +} INCLUDE "nrf52_common.ld" From e6e6e03482bcff947cac0b355668bf63fc152d10 Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sat, 17 Oct 2026 08:52:33 +0700 Subject: [PATCH 04/34] Custom MKE_S3 boards in Vietnam --- variants/mke_s3/MKES3Board.h | 13 ++ variants/mke_s3/platformio.ini | 230 +++++++++++++++++++++++++++++++++ variants/mke_s3/target.cpp | 46 +++++++ variants/mke_s3/target.h | 27 ++++ 4 files changed, 316 insertions(+) create mode 100644 variants/mke_s3/MKES3Board.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 000000000..a0ccf9e13 --- /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/platformio.ini b/variants/mke_s3/platformio.ini new file mode 100644 index 000000000..6ee537096 --- /dev/null +++ b/variants/mke_s3/platformio.ini @@ -0,0 +1,230 @@ +; 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} + +[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 + +[env:MKE_s3_repeater_bridge_rs232] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -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=5 + -D WITH_RS232_BRIDGE_TX=6 +; -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_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 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} \ No newline at end of file diff --git a/variants/mke_s3/target.cpp b/variants/mke_s3/target.cpp new file mode 100644 index 000000000..70c1d274c --- /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 000000000..4111cd404 --- /dev/null +++ b/variants/mke_s3/target.h @@ -0,0 +1,27 @@ +#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(); From 75742457bd67a17f880c8d6851a156138480729a Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sat, 17 Oct 2026 09:12:09 +0700 Subject: [PATCH 05/34] Added L2 Pro board --- 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 | 210 ++++++++++++++++++ variants/wio-tracker-l2/platformio.ini | 134 +++++++++++ variants/wio-tracker-l2/target.cpp | 35 +++ variants/wio-tracker-l2/target.h | 30 +++ 11 files changed, 1142 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 000000000..706ed4c17 --- /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 000000000..020f5e9b1 --- /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 000000000..26d1e4a9c --- /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 000000000..b84f4a771 --- /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 000000000..9e5f633c0 --- /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 000000000..edeaacce5 --- /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 000000000..c5644e2f2 --- /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 000000000..d9089a64d --- /dev/null +++ b/variants/wio-tracker-l2/WioTrackerL2Display.h @@ -0,0 +1,210 @@ +#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; + } + + // 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 000000000..9434d0818 --- /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 000000000..a87da3924 --- /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 000000000..11a66e4ec --- /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 5419b434630e64003ef4a577f7bd5bb8c9797fec Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sat, 17 Oct 2026 09:29:33 +0700 Subject: [PATCH 06/34] Custom build file of PowerSaving --- build-iotthinks.sh | 220 +++++++++++++++++++++++++++++++++++++++++++++ build.sh | 6 +- 2 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 build-iotthinks.sh diff --git a/build-iotthinks.sh b/build-iotthinks.sh new file mode 100644 index 000000000..ee42cd198 --- /dev/null +++ b/build-iotthinks.sh @@ -0,0 +1,220 @@ +# sh ./build-repeaters-iotthinks.sh +export FIRMWARE_VERSION="PowerSaving18" + +############# Repeaters ############# +# Commonly-used boards +## ESP32 - 23 boards +sh build.sh build-firmware \ +Heltec_ct62_repeater \ +Heltec_E290_repeater \ +Heltec_v3_repeater \ +heltec_v4_repeater \ +heltec_v4_r8_repeater \ +heltec_tracker_v2_repeater \ +Heltec_Wireless_Paper_repeater \ +Heltec_Wireless_Tracker_repeater \ +Heltec_WSL3_repeater \ +MKE_s3_repeater \ +meshnology_w12_repeater \ +LilyGo_T3S3_sx1262_repeater \ +LilyGo_TBeam_1W_repeater \ +LilyGo_TDeck_repeater \ +Station_G2_repeater \ +T_Beam_S3_Supreme_SX1262_repeater \ +Tbeam_SX1262_repeater \ +ThinkNode_M2_Repeater \ +ThinkNode_M5_Repeater \ +Xiao_C3_repeater \ +Xiao_C6_repeater_ \ +Xiao_S3_repeater \ +Xiao_S3_WIO_repeater + +## NRF52 - 24 boards +sh build.sh build-firmware \ +GAT562_30S_Mesh_Kit_repeater \ +GAT562_Mesh_Tracker_Pro_repeater \ +Heltec_mesh_solar_repeater \ +Heltec_t096_repeater \ +Heltec_t114_repeater \ +Heltec_t1_repeater \ +ikoka_nano_nrf_22dbm_repeater \ +ikoka_nano_nrf_30dbm_repeater \ +ikoka_nano_nrf_33dbm_repeater \ +LilyGo_T-Echo_Card_repeater \ +LilyGo_T-Echo_repeater \ +LilyGo_T-Echo-Lite_repeater \ +MeshTracker_X1_repeater \ +ProMicro_repeater \ +RAK_3401_repeater \ +RAK_4631_repeater \ +RAK_WisMesh_Tag_repeater \ +SenseCap_Solar_repeater \ +t1000e_repeater \ +ThinkNode_M1_repeater \ +ThinkNode_M3_repeater \ +ThinkNode_M6_repeater \ +WioTrackerL1_repeater \ +Xiao_nrf52_repeater + +## ESP32, SX1276 - 3 boards +sh build.sh build-firmware \ +Heltec_v2_repeater \ +LilyGo_TLora_V2_1_1_6_repeater \ +Tbeam_SX1276_repeater + +############# Room Server ############# +# ESP32 - 9 boards +sh build.sh build-firmware \ +Heltec_v3_room_server \ +heltec_v4_room_server \ +heltec_v4_r8_room_server \ +heltec_tracker_v2_room_server \ +Heltec_Wireless_Paper_room_server \ +Heltec_WSL3_room_server \ +MKE_s3_room_server \ +LilyGo_TBeam_1W_room_server \ +Xiao_S3_room_server + +# NRF52 - 8 boards +sh build.sh build-firmware \ +Heltec_t096_room_server \ +Heltec_t114_room_server \ +MeshTracker_X1_room_server \ +RAK_3401_room_server \ +RAK_4631_room_server \ +t1000e_room_server \ +WioTrackerL1_room_server \ +Xiao_nrf52_room_server + +############# Companions BLE ############# +# NRF52 - 22 boards +sh build.sh build-firmware \ +GAT562_30S_Mesh_Kit_companion_radio_ble \ +Heltec_t096_companion_radio_ble \ +Heltec_t1_companion_radio_ble \ +Heltec_t114_companion_radio_ble \ +MKE_s3_companion_radio_ble \ +Mesh_pocket_companion_radio_ble \ +LilyGo_T-Echo_Card_companion_radio_ble \ +LilyGo_T-Echo_companion_radio_ble \ +LilyGo_T-Echo-Lite_companion_radio_ble \ +LilyGo_T-Echo-Lite_non_shell_companion_radio_ble \ +MeshTracker_X1_companion_radio_ble \ +ProMicro_companion_radio_ble \ +RAK_3401_companion_radio_ble \ +RAK_4631_companion_radio_ble \ +RAK_WisMesh_Tag_companion_radio_ble \ +SenseCap_Solar_companion_radio_ble \ +t1000e_companion_radio_ble \ +ThinkNode_M1_companion_radio_ble \ +ThinkNode_M3_companion_radio_ble \ +ThinkNode_M6_companion_radio_ble \ +WioTrackerL1_companion_radio_ble \ +Xiao_nrf52_companion_radio_ble + +############# Companions BLE PS ############# +# ESP32 - 22 boards +sh build.sh build-firmware \ +Heltec_ct62_companion_radio_ble \ +heltec_tracker_v2_companion_radio_ble \ +Heltec_v2_companion_radio_ble \ +Heltec_v3_companion_radio_ble \ +heltec_v4_companion_radio_ble \ +heltec_v4_expansionkit_tft_companion_radio_ble \ +heltec_v4_r8_companion_radio_ble \ +Heltec_Wireless_Paper_companion_radio_ble \ +Heltec_Wireless_Tracker_companion_radio_ble \ +Heltec_WSL3_companion_radio_ble \ +meshnology_w12_companion_radio_ble \ +MKE_s3_companion_radio_usb \ +LilyGo_T3S3_sx1262_companion_radio_ble \ +LilyGo_TBeam_1W_companion_radio_ble \ +LilyGo_TDeck_companion_radio_ble \ +LilyGo_TLora_V2_1_1_6_companion_radio_ble \ +T_Beam_S3_Supreme_SX1262_companion_radio_ble \ +Tbeam_SX1262_companion_radio_ble \ +Tbeam_SX1276_companion_radio_ble \ +ThinkNode_M5_companion_radio_ble \ +Xiao_C3_companion_radio_ble \ +Xiao_S3_companion_radio_ble \ +Xiao_S3_WIO_companion_radio_ble + +############# Companions USB ############# +# 14 boards +sh build.sh build-firmware \ +Heltec_t096_companion_radio_usb \ +heltec_tracker_v2_companion_radio_usb \ +Heltec_v3_companion_radio_usb \ +heltec_v4_companion_radio_usb \ +LilyGo_T-Echo-Lite_non_shell_companion_radio_usb \ +LilyGo_TBeam_1W_companion_radio_usb \ +LilyGo_TDeck_companion_radio_usb \ +Mesh_pocket_companion_radio_usb \ +MeshTracker_X1_companion_radio_usb \ +MKE_s3_companion_radio_usb \ +ThinkNode_M2_companion_radio_usb \ +Xiao_C3_companion_radio_usb \ +Xiao_S3_companion_radio_usb \ +Xiao_S3_WIO_companion_radio_usb + +############# Sensor ############# +# NRF52 - 2 boards +sh build.sh build-firmware \ +Heltec_t096_sensor \ +Heltec_t114_sensor \ +t1000e_sensor + +############# Bridge ############# +# 1 board +sh build.sh build-firmware \ +Xiao_nrf52_repeater_bridge_rs232 + +############# Sample builds ############# +# 23 boards +sh build.sh build-firmware \ +Heltec_t096_companion_radio_ble \ +Heltec_t096_repeater \ +Heltec_t114_companion_radio_ble \ +Heltec_t114_repeater \ +Heltec_v3_companion_radio_ble \ +Heltec_v3_repeater \ +heltec_v4_companion_radio_ble \ +heltec_v4_repeater \ +ProMicro_repeater \ +RAK_3401_companion_radio_ble \ +RAK_3401_repeater \ +RAK_4631_companion_radio_ble \ +RAK_4631_repeater \ +SenseCap_Solar_companion_radio_ble \ +SenseCap_Solar_repeater \ +WioTrackerL1_companion_radio_ble \ +WioTrackerL1_repeater \ +Xiao_C3_companion_radio_ble \ +Xiao_C3_repeater \ +Xiao_C6_companion_radio_ble_ \ +Xiao_C6_repeater_ \ +Xiao_nrf52_companion_radio_ble \ +Xiao_nrf52_repeater + +# 2 boards +sh build.sh build-firmware \ +Heltec_t096_companion_radio_usb \ +heltec_v4_companion_radio_usb + +# Mini sample builds +sh build.sh build-firmware \ +Heltec_t096_companion_radio_ble \ +Heltec_t096_companion_radio_usb \ +Heltec_t096_repeater \ +heltec_v4_companion_radio_ble \ +heltec_v4_repeater \ +RAK_3401_companion_radio_ble \ +RAK_3401_repeater \ +RAK_4631_companion_radio_ble \ +RAK_4631_repeater \ +Xiao_C3_companion_radio_ble \ +Xiao_C3_repeater \ +Xiao_C6_companion_radio_ble_ \ +Xiao_C6_repeater_ \ +Xiao_nrf52_companion_radio_ble \ +Xiao_nrf52_repeater \ No newline at end of file diff --git a/build.sh b/build.sh index 80ff4cdf2..a0841e76a 100755 --- a/build.sh +++ b/build.sh @@ -140,7 +140,7 @@ build_firmware() { # set firmware version string # e.g: v1.0.0-abcdef - FIRMWARE_VERSION_STRING="${FIRMWARE_VERSION}-${COMMIT_HASH}" + FIRMWARE_VERSION_STRING="${FIRMWARE_VERSION}" # craft filename # e.g: RAK_4631_Repeater-v1.0.0-SHA @@ -158,8 +158,8 @@ build_firmware() { # build merge-bin for esp32 fresh install, copy .bins to out folder (e.g: Heltec_v3_room_server-v1.0.0-SHA.bin) if [ "$ENV_PLATFORM" == "ESP32_PLATFORM" ]; then pio run -t mergebin -e $1 - cp .pio/build/$1/firmware.bin out/${FIRMWARE_FILENAME}.bin 2>/dev/null || true - cp .pio/build/$1/firmware-merged.bin out/${FIRMWARE_FILENAME}-merged.bin 2>/dev/null || true + cp .pio/build/$1/firmware.bin out/${FIRMWARE_FILENAME}-upgrade.bin 2>/dev/null || true + cp .pio/build/$1/firmware-merged.bin out/${FIRMWARE_FILENAME}-freshInstall-merged.bin 2>/dev/null || true fi # build .uf2 for nrf52 boards, copy .uf2 and .zip to out folder (e.g: RAK_4631_Repeater-v1.0.0-SHA.uf2) From 0db7fc4f17f8b732b0a239445ed282bbfe330852 Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sat, 17 Oct 2026 09:39:29 +0700 Subject: [PATCH 07/34] Custom libraries for PS, RXPS and new RadioLib --- platformio.ini | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/platformio.ini b/platformio.ini index f296f8382..3cd8e0508 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 @@ -29,6 +28,15 @@ build_flags = -w -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1 -D LORA_FREQ=869.618 -D LORA_BW=62.5 -D LORA_SF=8 + ; TCXO startup delay in microseconds, written by CustomSX1262/CustomLR1110 + ; after begin(). RadioLib defaults to 5000 and nothing used to override it, so + ; every board paid 5 ms on every duty-cycle wake. Lowest working value measured + ; on five modules: T096 400, Tracker V2 300, Waveshare 150, ThinkNode M3 200, + ; T1000-E 200 us - 1600 keeps 4x margin over the worst of them. It also feeds + ; the RXPS sleep floor and timer guard (transition = this + 1000), so lowering + ; it makes low-SF profiles armable that were not. A board needing its own value + ; puts -D MC_TCXO_DELAY_US=... in its own build_flags; the later -D wins. + -D MC_TCXO_DELAY_US=1600 -D ENABLE_ADVERT_ON_BOOT=1 -D ENABLE_PRIVATE_KEY_IMPORT=1 ; NOTE: comment these out for more secure firmware -D ENABLE_PRIVATE_KEY_EXPORT=1 @@ -58,6 +66,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} @@ -73,8 +84,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 --------------------- @@ -92,7 +109,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 @@ -118,6 +138,7 @@ build_flags = ${arduino_base.build_flags} -D STM32_PLATFORM -I src/helpers/stm32 -I $PROJECT_PACKAGES_DIR/framework-arduinoststm32/libraries/SubGhz/src + -fpermissive build_src_filter = ${arduino_base.build_src_filter} + lib_deps = ${arduino_base.lib_deps} @@ -174,6 +195,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 From d30a69ef907fd5a953a439c27f0c5c28c6a85b1b Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sat, 17 Oct 2026 09:48:16 +0700 Subject: [PATCH 08/34] Custom docs for PS and RXPS --- docs/cli_commands.md | 195 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 9fde4d436..5963acdcf 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -277,6 +277,39 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +#### View or change RX duty-cycle power saving +**Usage:** +- `get radio.rxps` +- `set radio.rxps off` +- `set radio.rxps on` +- `set radio.rxps conservative` +- `set radio.rxps balanced` +- `set radio.rxps max` +- `set radio.rxps max preamble <16|32>` +- `set radio.rxps overdrive` +- `set radio.rxps overdrive preamble <16|32>` +- `set radio.rxps riskyWorkingMax` +- `set radio.rxps riskyWorkingMax preamble <16|32>` +- `set radio.rxps <1-10>` +- `set radio.rxps level <1-10>` +- `set radio.rxps level <1-10> preamble <16|32>` +- `set radio.rxps ` + +**Parameters:** +- `rx_us`, `sleep_us`: Receive and sleep durations in microseconds (`1000`-`30000000`). +- `level`: `1`-`8`, expressed as **symbols of the sender's preamble the receiver catches** - how much of it lands inside an open RX window. Level `1` catches the most (15 symbols on the 16-symbol profile, 24 on the 32-symbol one) and costs the most power; level `8` catches 8 on both profiles and costs the least. Eight is the floor because that is what an LR11x0 needs to latch, so a level means the same geometry on either radio family. `9` (`overdrive`) and `10` (`riskyWorkingMax`) go below that floor and outside the datasheet timer condition; see below. +- `preamble`: LoRa preamble length in symbols; `16` or `32`. +- `state`: `on` or `off`. + +**Notes:** +- `on` and `conservative` select level `3` (catches 13 symbols), `balanced` selects level `6` (catches 10), and `max` selects level `8` (catches 8); all three use a 16-symbol preamble. +- Levels `1`-`8` satisfy the SX1261/2 duty-cycle timer condition; `max` is the top of that range. `overdrive` (level `9`) and `riskyWorkingMax` (level `10`) trade that guarantee for lower measured duty cycles; see the fuller entries below. +- Like the other named profiles, `max`, `overdrive`, and `riskyWorkingMax` assume a 16-symbol sender preamble. At SF5-SF8 that is markedly less economical than the equivalent numeric level, which follows the SF onto the 32-symbol profile. Use the explicit `preamble 32` form to select the 32-symbol profile. +- Level-based settings automatically recalculate their timings when the spreading factor or bandwidth changes. Custom ` ` timings remain fixed. +- The selected mode is applied immediately, persisted, and restored after reboot. + +--- + #### View or change the LoRa FEM receive-path gain state on supported boards **Usage:** - `get radio.fem.rxgain` @@ -307,6 +340,135 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +#### View or change RX power saving +**Usage:** +- `get radio.rxps` +- `set radio.rxps off` +- `set radio.rxps on` +- `set radio.rxps conservative` +- `set radio.rxps balanced` +- `set radio.rxps max` +- `set radio.rxps max preamble ` +- `set radio.rxps overdrive` +- `set radio.rxps overdrive preamble ` +- `set radio.rxps riskyWorkingMax` +- `set radio.rxps riskyWorkingMax preamble ` +- `set radio.rxps ` +- `set radio.rxps level ` +- `set radio.rxps level preamble ` +- `set radio.rxps ` + +**Parameters:** +- `level`: `1-10`. Levels `1`-`8` are the guarded scale and are expressed in **symbols of the sender's preamble caught**: 15, 14, 13, 12, 11, 10, 9, 8 on the 16-symbol profile and 24, 20, 16, 14, 12, 10, 9, 8 on the 32-symbol one. Level `8` is named `max`. `9` (`overdrive`) and `10` (`riskyWorkingMax`) drop below the 8-symbol floor and outside the datasheet timer condition; only `10` is expected to lose packets. +- `symbols`: `16` or `32` preamble symbols. +- `rx_us`: receive-window duration in microseconds, `1000-30000000`. +- `sleep_us`: radio sleep duration in microseconds, `1000-30000000`. + +**Repeater default:** `off` + +**Profiles:** +- `on` and `conservative`: level 3, catches 13 symbols, with a 16-symbol preamble. +- `balanced`: level 6, catches 10 symbols, with a 16-symbol preamble. +- `max`: level 8, catches the 8-symbol floor, the cheapest guarded setting that still works on both radio families, with a 16-symbol preamble. +- `overdrive`: level 9, with a 16-symbol preamble. Outside the datasheet timer condition; see below. +- `riskyWorkingMax`: level 10, with a 16-symbol preamble. The measured edge of what still works, and the only setting here that is expected to drop packets; see below. +- Every named profile assumes a 16-symbol sender, which is the worst case a mixed network can present. At SF5-SF8 this is less economical than a numeric level, because a 32-symbol profile cannot catch a 16-symbol preamble. `max preamble 32`, `overdrive preamble 32`, and `riskyWorkingMax preamble 32` select the 32-symbol profile explicitly. +- A numeric level, or `level `, automatically uses 32 preamble symbols for SF5-SF8 and 16 for SF9-SF12. +- `level preamble ` explicitly fixes the preamble used in the calculation. +- Explicit `rx_us sleep_us` values select manual timing (`level=0`). + +**How the timings are derived:** + +```text +sleep = (preamble - caught symbols) symbols +listen = whatever the duty-cycle timer condition needs at that sleep +``` + +The capture cost - 6 symbols on SX126x, 8 on LR11x0, both measured on the bench - +no longer enters this arithmetic; it is only a validation, since a level that +catches fewer symbols than the radio needs to latch is rejected. That is why the +same level produces the same periods on either family. The listen window is not +a free parameter: +it follows from the timer condition below. Three constraints are applied on top, +all of them measured rather than assumed: + +- **Sleep floor.** The driver subtracts the sleep-to-RX transition (`tcxoDelay + 1000 us`) from the requested sleep before writing the register. Below that the arm call fails outright; just above it the SX1262 arms with no error and then detects no preambles at all. Levels whose own sleep falls under the floor are raised to it, and collapse onto the same timing as a result. If even the floor would break preamble capture - a short symbol leaves no room between the two - the level is rejected with `ERROR: RXPS does not fit this SF/BW with preamble ` rather than silently falling back to continuous RX. +- **Timer condition.** Semtech requires `Tpreamble + Theader <= 2 * rxPeriod + sleepPeriod`, because the radio restarts its receive timer with that value when it detects a preamble. Guarded levels `1`-`8` satisfy it; `overdrive` and `riskyWorkingMax` deliberately do not. When it is broken, an SX1262 usually still receives normally, but isolated `rxPeriod` register values one tick wide lose most of the packets they have already latched, and nothing in the value predicts which. LR11x0 has always enforced an equivalent rule in its driver. +- **Register granularity.** Both radio families program the duty cycle in 15.625 us ticks and truncate on the way in, so the reported periods are snapped to whole ticks and are the ones the hardware actually runs. + +**`overdrive` (level 9):** + +`overdrive` is the geometry that predates the timer condition being enforced: an +8-symbol receive window with the sleep at the capture limit. It breaks the +condition deliberately, in exchange for the lowest duty cycle available. The +name is meant in the overclocking sense - measured to work, outside the vendor's +stated envelope - rather than as a warning that it drops packets. It does not. + +| Assumed sender preamble | `overdrive` | Level 10 | Saving | +|---|---|---|---| +| 32 symbols | 23.5% | 27.5% (SF8) - 33.4% (SF6) | 4.0 - 9.9 pp | +| 16 symbols | 44.4% | 49.7% (SF8) - 56.6% (SF6) | 5.3 - 12.2 pp | + +Both figures are dimensionless in symbols, so they hold at any spreading factor. +The sleep floor and tick rounding still apply, and the receive window is moved +one tick if it lands on a register value measured to misbehave. That list of +values is not exhaustive, which is the whole of the residual risk: levels +`1`-`8` are safe on any register value, `overdrive` is safe on every value that +has been measured. It is a text-CLI setting only and is never selected by +companion firmware. + +**`riskyWorkingMax` (level 10):** + +The end of the road: the profile extrapolated past `overdrive` until packet +delivery started to fall, then stepped back to the last setting that still held. The +sleep goes past the capture budget on purpose, so a preamble sometimes arrives +while the radio is asleep - which is exactly why delivery falls. Measured at SF8 +with an LR1110 receiver witnessing every transmission on the same link: + +| Assumed sender preamble | Delivery | Sleep share | vs `overdrive` | +|---|---|---|---| +| 32 symbols | 196/200 (98.0%) | 79.3% | +2.8 pp sleep, -2.0 pp delivery | +| 16 symbols | 197/200 (98.5%) | 56.4% | +0.9 pp sleep, -1.0 pp delivery | + +`overdrive` on the same link and in the same run read 200/200 and 199/200. So +this level buys a little sleep and pays for it in packets, which is the whole +trade and the reason the name is what it is. The command is case-sensitive and +has no short form. On a mesh that relies on retries the cost may be acceptable; +on a link that matters it is not. + +Level-based settings are recalculated after SF or bandwidth changes. Manual timings are not recalculated. Settings are persisted in `/prefs.json`. Companion firmware does not expose this text command and applies its fixed `balanced` profile (level 6, catches 10 symbols, preamble 16) at startup and after radio-parameter changes. + +`set radio.rxps` echoes the timings it applied, so the periods the radio really +runs are visible immediately: + +```text +OK - on,level=9(overdrive),preamble=16,rx=32782,sleep=40954 +OK - on,level=10,preamble=32,rx=40375,sleep=106500 +OK - on,level=5,preamble=16,rx=49485,sleep=22750 +``` + +`get radio.rxps` reports: + +```text +desired=,effective=,supported=, +level=<0-10>[(overdrive)|(riskyWorkingMax)][,catch=],preamble=<0|16|32>,rx=,sleep=, +err=,fail=[,erx=,eslp=] +``` + +- `desired` is the saved user setting. +- `level=9(overdrive)` and `level=10(riskyWorkingMax)` mark profiles that run outside the datasheet timer condition. Any other level is guarded. +- `catch` is how many symbols of a sender's preamble the node actually catches, and appears only for guarded levels. It is not simply the number the level asks for: the sleep floor can shorten the sleep further, and at SF6 with a 16-symbol profile that collapses levels 1-6 onto the same point. Reporting the requested value there would be a lie, so the effective one is reported instead. +- `effective=armed` means receive duty-cycle is active. +- `effective=continuous` means RXPS is disabled, unsupported, or the last arm attempt fell back to continuous RX. +- `fail` counts failed arm operations; each one falls back to continuous RX. `clear stats` resets both this total and the consecutive-failure backoff, granting three fresh arm attempts. +- `erx` and `eslp` appear only when the driver had to clamp the requested periods, and report the effective periods after driver clamping. On LR1110 the RX window is stretched when `2*rx + sleep` would not cover the extended period Semtech requires, so the real duty cycle can be less economical than `rx`/`sleep` suggest. +- RXPS is currently supported by the SX1262 and LR1110 wrappers. Other radios remain in continuous RX and reject attempts to enable RXPS. +- There is intentionally no RXPS watchdog, watchdog command, or periodic recovery. Recovery is limited to the immediate continuous-RX fallback after an arm error. After 3 consecutive arm failures the node stops retrying on every RX restart and stays in continuous RX until the RXPS configuration is set again or `clear stats` grants a fresh set of attempts. +- On boards with a host-controlled RXEN pin, the RF switch is held in receive mode for the whole duty cycle (otherwise the node would be deaf). An external LNA on that pin therefore stays biased during the sleep windows, so the real power saving is smaller than the `rx`/`sleep` ratio implies. + +--- + + ### System #### View or change this node's name @@ -461,6 +623,18 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- +#### View or set reboot interval (Repeater and room server) +**Usage:** +- `get reboot.interval` +- `set reboot.interval ` + +**Parameters:** +- `hours`: 0-255. 0 is disabled + +**Default:** `0` (disabled) + +--- + ### Routing #### View or change this node's repeat flag @@ -826,6 +1000,27 @@ adverts running, set an explicit value in the 60-240 range. --- +#### View or set the direct path override for the current remote client +**Usage:** +- `get outpath` +- `set outpath ` +- `set outpath direct` +- `set outpath clear` +- `set outpath flood` + +**Parameters:** +- `hopN_hex`: Hop hash, `2`, `4`, or `6` hex characters. All hops must use the same width. + +**Notes:** +- These commands require remote client context (they target the caller's ACL entry). +- The path hash size is inferred from the hop hash width. +- `outpath` overrides the primary direct route used for replies to the caller. +- `direct` sets a zero-hop direct route for a caller reachable without repeaters. +- `clear` forgets the current direct path and allows normal path discovery to repopulate it. +- `flood` forces replies to use flood packets until the client logs in again. + +--- + #### Create a new region **Usage:** - `region put [parent_name]` From 2d94fe325dbaf838e2401099f641a3f8f0dc39d7 Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sat, 17 Oct 2026 09:54:16 +0700 Subject: [PATCH 09/34] Added latest test_rx_power_saving --- .../test_rx_power_saving.cpp | 894 ++++++++++++++++++ 1 file changed, 894 insertions(+) create mode 100644 test/test_rx_power_saving/test_rx_power_saving.cpp 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 000000000..c17c875c4 --- /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 48a8e9ee8c9492c9e6920ed5d05b55bfbbf012af Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sat, 17 Oct 2026 11:40:02 +0700 Subject: [PATCH 10/34] PowerSaving changes to variants: - Fixed powerOff, shutdownPeripherals and added enterDeepSleep() - Keep time across reset for NRF52 - Minor fixes --- .../GAT56230SMeshKitBoard.h | 6 ++--- variants/gat562_30s_mesh_kit/platformio.ini | 1 + .../gat562_mesh_evb_pro/GAT562EVBProBoard.h | 6 ++--- .../GAT562MeshTrackerProBoard.h | 6 ++--- .../gat562_mesh_tracker_pro/platformio.ini | 2 ++ .../GAT56MeshWatch13Board.h | 6 ++--- variants/heltec_rc32/HeltecRC32Board.cpp | 4 --- variants/heltec_rc32/HeltecRC32Board.h | 1 - variants/heltec_t096/T096Board.cpp | 5 ++-- variants/heltec_t096/T096Board.h | 2 +- variants/heltec_t096/platformio.ini | 2 ++ variants/heltec_t1/T1Board.cpp | 5 ++-- variants/heltec_t1/T1Board.h | 2 +- variants/heltec_t114/T114Board.h | 6 ++--- variants/heltec_t114/platformio.ini | 18 +++++++++++++ .../heltec_tower_v2/HeltecTowerV2Board.cpp | 7 ++--- variants/heltec_tower_v2/HeltecTowerV2Board.h | 2 +- .../HeltecTrackerV2Board.cpp | 6 ++--- .../heltec_tracker_v2/HeltecTrackerV2Board.h | 2 +- variants/heltec_tracker_v2/platformio.ini | 23 ++++++++++++++++ variants/heltec_v4/HeltecV4Board.cpp | 6 ++--- variants/heltec_v4/HeltecV4Board.h | 2 +- variants/heltec_v4/platformio.ini | 8 ++++++ variants/heltec_v4_r8/HeltecV4R8Board.cpp | 26 +++---------------- variants/heltec_v4_r8/HeltecV4R8Board.h | 3 +-- variants/lilygo_techo_lite/variant.cpp | 4 +-- variants/lilygo_tlora_v2_1/platformio.ini | 1 + variants/meshtiny/MeshtinyBoard.h | 8 +++--- .../MinewsemiME25LS01Board.h | 5 ++-- variants/muziworks_r1_neo/R1NeoBoard.h | 2 ++ variants/nano_g2_ultra/nano-g2.h | 6 ++--- variants/promicro/platformio.ini | 1 + variants/rak4631/platformio.ini | 5 +++- variants/rak_wismesh_tag/RAKWismeshTagBoard.h | 6 ++--- variants/rak_wismesh_tag/platformio.ini | 1 + variants/sensecap_solar/SenseCapSolarBoard.h | 8 +++++- variants/station_g3_esp32/StationG3Board.cpp | 6 ++--- variants/station_g3_esp32/StationG3Board.h | 2 +- variants/t1000-e/T1000eBoard.h | 6 ++--- variants/t1000-e/platformio.ini | 17 ++++++++++++ variants/thinknode_m1/ThinkNodeM1Board.h | 6 ++--- variants/thinknode_m3/ThinkNodeM3Board.h | 7 +++-- variants/thinknode_m6/ThinkNodeM6Board.h | 6 ++--- variants/thinknode_m6/platformio.ini | 2 +- variants/wio-tracker-l1/WioTrackerL1Board.h | 4 --- variants/wio-tracker-l1/platformio.ini | 4 +++ variants/wio_wm1110/platformio.ini | 12 ++++----- variants/xiao_nrf52/XiaoNrf52Board.h | 6 ++--- variants/xiao_nrf52/platformio.ini | 19 ++++++++++++++ variants/xiao_s3_wio/platformio.ini | 6 ++++- variants/xiao_s3_wio/target.cpp | 9 ++++++- 51 files changed, 201 insertions(+), 115 deletions(-) diff --git a/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h b/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h index 0b0f701c2..82034c7e7 100644 --- a/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h +++ b/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h @@ -43,11 +43,11 @@ public: } #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_30s_mesh_kit/platformio.ini b/variants/gat562_30s_mesh_kit/platformio.ini index 326e3f705..19afd43b4 100644 --- a/variants/gat562_30s_mesh_kit/platformio.ini +++ b/variants/gat562_30s_mesh_kit/platformio.ini @@ -2,6 +2,7 @@ extends = nrf52_base board = rak4631 board_check = true +board_build.ldscript = boards/nrf52840_s140_v6.ld build_flags = ${nrf52_base.build_flags} ${sensor_base.build_flags} -I variants/gat562_30s_mesh_kit diff --git a/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h b/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h index 66c770994..a3d738cac 100644 --- a/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h +++ b/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h @@ -43,11 +43,11 @@ public: } #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 39b55911a..f179cdc7f 100644 --- a/variants/gat562_mesh_tracker_pro/GAT562MeshTrackerProBoard.h +++ b/variants/gat562_mesh_tracker_pro/GAT562MeshTrackerProBoard.h @@ -43,11 +43,11 @@ public: } #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/platformio.ini b/variants/gat562_mesh_tracker_pro/platformio.ini index 1141cee62..a7a2124a2 100644 --- a/variants/gat562_mesh_tracker_pro/platformio.ini +++ b/variants/gat562_mesh_tracker_pro/platformio.ini @@ -2,9 +2,11 @@ extends = nrf52_base board = rak4631 board_check = true +board_build.ldscript = boards/nrf52840_s140_v6.ld build_flags = ${nrf52_base.build_flags} ${sensor_base.build_flags} -I variants/gat562_mesh_tracker_pro +; -D NRF52_POWER_MANAGEMENT -D PIN_BOARD_SCL=14 -D PIN_BOARD_SDA=13 -D PIN_OLED_RESET=-1 diff --git a/variants/gat562_mesh_watch13/GAT56MeshWatch13Board.h b/variants/gat562_mesh_watch13/GAT56MeshWatch13Board.h index 805ca67f2..d8fcb2bb7 100644 --- a/variants/gat562_mesh_watch13/GAT56MeshWatch13Board.h +++ b/variants/gat562_mesh_watch13/GAT56MeshWatch13Board.h @@ -34,11 +34,11 @@ public: } - 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 59920d238..1ead34bfa 100644 --- a/variants/heltec_rc32/HeltecRC32Board.cpp +++ b/variants/heltec_rc32/HeltecRC32Board.cpp @@ -31,10 +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 5b2093c8f..e53111e71 100644 --- a/variants/heltec_rc32/HeltecRC32Board.h +++ b/variants/heltec_rc32/HeltecRC32Board.h @@ -21,7 +21,6 @@ public: 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 ea26d2cc1..d55951046 100644 --- a/variants/heltec_t096/T096Board.cpp +++ b/variants/heltec_t096/T096Board.cpp @@ -111,10 +111,11 @@ 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 e926c8a6a..70e2693e5 100644 --- a/variants/heltec_t096/T096Board.h +++ b/variants/heltec_t096/T096Board.h @@ -32,5 +32,5 @@ public: void onAfterTransmit(void) override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override ; - void powerOff() override; + void shutdownPeripherals() override; }; diff --git a/variants/heltec_t096/platformio.ini b/variants/heltec_t096/platformio.ini index 1f572fc9b..6b0377cc0 100644 --- a/variants/heltec_t096/platformio.ini +++ b/variants/heltec_t096/platformio.ini @@ -68,6 +68,7 @@ build_flags = -D MAX_NEIGHBOURS=50 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 +; -D POWERSAVING_DEBUG=1 [env:Heltec_t096_repeater_bridge_rs232] extends = Heltec_t096 @@ -134,6 +135,7 @@ build_flags = -D OFFLINE_QUEUE_SIZE=256 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 +; -D POWERSAVING_DEBUG=1 build_src_filter = ${Heltec_t096.build_src_filter} + +<../examples/companion_radio/*.cpp> diff --git a/variants/heltec_t1/T1Board.cpp b/variants/heltec_t1/T1Board.cpp index 1ef5b766b..a54b8eae4 100644 --- a/variants/heltec_t1/T1Board.cpp +++ b/variants/heltec_t1/T1Board.cpp @@ -97,9 +97,10 @@ 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 9b46fd11a..3e9afd1b9 100644 --- a/variants/heltec_t1/T1Board.h +++ b/variants/heltec_t1/T1Board.h @@ -22,5 +22,5 @@ public: 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 bd76d3de4..6d7664cbd 100644 --- a/variants/heltec_t114/T114Board.h +++ b/variants/heltec_t114/T114Board.h @@ -46,11 +46,11 @@ public: 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_t114/platformio.ini b/variants/heltec_t114/platformio.ini index e795bc7fa..d7a72ca88 100644 --- a/variants/heltec_t114/platformio.ini +++ b/variants/heltec_t114/platformio.ini @@ -12,6 +12,7 @@ build_flags = ${nrf52_base.build_flags} -I variants/heltec_t114 -I src/helpers/ui -D HELTEC_T114 +; -D NRF52_POWER_MANAGEMENT -D P_LORA_DIO_1=20 -D P_LORA_NSS=24 -D P_LORA_RESET=25 @@ -172,6 +173,7 @@ build_flags = -D MAX_NEIGHBOURS=50 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 +; -D POWERSAVING_DEBUG=1 [env:Heltec_t114_repeater_bridge_rs232] extends = Heltec_t114 @@ -221,6 +223,7 @@ build_flags = -D OFFLINE_QUEUE_SIZE=256 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 +; -D POWERSAVING_DEBUG=1 build_src_filter = ${Heltec_t114_with_display.build_src_filter} + +<../examples/companion_radio/*.cpp> @@ -255,3 +258,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_tower_v2/HeltecTowerV2Board.cpp b/variants/heltec_tower_v2/HeltecTowerV2Board.cpp index 04213bda8..a02f8e9c2 100644 --- a/variants/heltec_tower_v2/HeltecTowerV2Board.cpp +++ b/variants/heltec_tower_v2/HeltecTowerV2Board.cpp @@ -88,7 +88,9 @@ 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 +101,4 @@ void HeltecTowerV2Board::powerOff() { pinMode(PIN_BAT_CTL, OUTPUT); digitalWrite(PIN_BAT_CTL, LOW); variant_shutdown(); - sd_power_system_off(); -} +} \ No newline at end of file diff --git a/variants/heltec_tower_v2/HeltecTowerV2Board.h b/variants/heltec_tower_v2/HeltecTowerV2Board.h index 6912acdec..b5e7f1271 100644 --- a/variants/heltec_tower_v2/HeltecTowerV2Board.h +++ b/variants/heltec_tower_v2/HeltecTowerV2Board.h @@ -20,5 +20,5 @@ public: 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 753824a05..f0136698b 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 5fed2b655..091a39d65 100644 --- a/variants/heltec_tracker_v2/HeltecTrackerV2Board.h +++ b/variants/heltec_tracker_v2/HeltecTrackerV2Board.h @@ -23,7 +23,7 @@ public: 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_tracker_v2/platformio.ini b/variants/heltec_tracker_v2/platformio.ini index 5fd9edabe..c3200c0a9 100644 --- a/variants/heltec_tracker_v2/platformio.ini +++ b/variants/heltec_tracker_v2/platformio.ini @@ -99,6 +99,29 @@ lib_deps = ${Heltec_tracker_v2.lib_deps} ${esp32_ota.lib_deps} +[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 + [env:heltec_tracker_v2_room_server] extends = Heltec_tracker_v2 build_flags = diff --git a/variants/heltec_v4/HeltecV4Board.cpp b/variants/heltec_v4/HeltecV4Board.cpp index 794257d76..0203bd4bb 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 79a690e84..1f83eee7c 100644 --- a/variants/heltec_v4/HeltecV4Board.h +++ b/variants/heltec_v4/HeltecV4Board.h @@ -29,7 +29,7 @@ public: 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/platformio.ini b/variants/heltec_v4/platformio.ini index 76e8e714e..11897c5aa 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 @@ -392,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 = diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.cpp b/variants/heltec_v4_r8/HeltecV4R8Board.cpp index ea2cfbedb..8a6858f3a 100644 --- a/variants/heltec_v4_r8/HeltecV4R8Board.cpp +++ b/variants/heltec_v4_r8/HeltecV4R8Board.cpp @@ -39,30 +39,10 @@ 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); +void HeltecV4R8Board::shutdownPeripherals() { + ESP32Board::shutdownPeripherals(); + 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); } uint16_t HeltecV4R8Board::getBattMilliVolts() { diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.h b/variants/heltec_v4_r8/HeltecV4R8Board.h index 7ab29f962..03cbf10db 100644 --- a/variants/heltec_v4_r8/HeltecV4R8Board.h +++ b/variants/heltec_v4_r8/HeltecV4R8Board.h @@ -30,8 +30,7 @@ public: 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_techo_lite/variant.cpp b/variants/lilygo_techo_lite/variant.cpp index 3cd82d709..2a56e5866 100644 --- a/variants/lilygo_techo_lite/variant.cpp +++ b/variants/lilygo_techo_lite/variant.cpp @@ -22,8 +22,8 @@ void initVariant() { pinMode(LED_RED, OUTPUT); pinMode(LED_GREEN, OUTPUT); - pinMode(LED_BLUE, OUTPUT); - digitalWrite(LED_BLUE, HIGH); + // pinMode(LED_BLUE, OUTPUT); + // digitalWrite(LED_BLUE, HIGH); digitalWrite(LED_GREEN, HIGH); digitalWrite(LED_RED, HIGH); diff --git a/variants/lilygo_tlora_v2_1/platformio.ini b/variants/lilygo_tlora_v2_1/platformio.ini index dc60b6f75..fb609979d 100644 --- a/variants/lilygo_tlora_v2_1/platformio.ini +++ b/variants/lilygo_tlora_v2_1/platformio.ini @@ -52,6 +52,7 @@ build_flags = ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 ; -D CORE_DEBUG_LEVEL=3 + -D PIN_USER_BTN=-1 ; Disabled button press which prevents repeater to boot lib_deps = ${LilyGo_TLora_V2_1_1_6.lib_deps} ${esp32_ota.lib_deps} diff --git a/variants/meshtiny/MeshtinyBoard.h b/variants/meshtiny/MeshtinyBoard.h index 21673d0aa..ba42d6736 100644 --- a/variants/meshtiny/MeshtinyBoard.h +++ b/variants/meshtiny/MeshtinyBoard.h @@ -9,7 +9,7 @@ protected: uint8_t btn_prev_state; public: - MeshtinyBoard() : NRF52Board("Meshtiny_OTA") {} + MeshtinyBoard() : NRF52Board("Meshtiny OTA") {} void begin(); #if defined(P_LORA_TX_LED) @@ -34,7 +34,8 @@ public: void reboot() override { NVIC_SystemReset(); } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); #ifdef PIN_USER_BTN while (digitalRead(PIN_USER_BTN) == LOW) { @@ -59,8 +60,5 @@ public: #ifdef PIN_USER_BTN 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/minewsemi_me25ls01/MinewsemiME25LS01Board.h b/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h index 450a3d13b..e4bbc7d06 100644 --- a/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h +++ b/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h @@ -45,7 +45,9 @@ public: return "Minewsemi"; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); + #ifdef HAS_GPS digitalWrite(GPS_VRTC_EN, LOW); digitalWrite(GPS_RESET, LOW); @@ -65,7 +67,6 @@ public: #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 c56f1f5c2..47b705719 100644 --- a/variants/muziworks_r1_neo/R1NeoBoard.h +++ b/variants/muziworks_r1_neo/R1NeoBoard.h @@ -20,6 +20,8 @@ public: #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 bf9543b03..8e5875c4c 100644 --- a/variants/nano_g2_ultra/nano-g2.h +++ b/variants/nano_g2_ultra/nano-g2.h @@ -43,7 +43,9 @@ public: 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 @@ -51,7 +53,5 @@ public: nrf_gpio_cfg_sense_input(digitalPinToInterrupt(PIN_USER_BTN), NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_SENSE_LOW); - - NRF52Board::powerOff(); } }; diff --git a/variants/promicro/platformio.ini b/variants/promicro/platformio.ini index da27c2fab..38d1691d7 100644 --- a/variants/promicro/platformio.ini +++ b/variants/promicro/platformio.ini @@ -1,6 +1,7 @@ [Promicro] extends = nrf52_base board = promicro_nrf52840 +board_build.ldscript = boards/nrf52840_s140_v6.ld build_flags = ${nrf52_base.build_flags} -I variants/promicro -D PROMICRO diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index d2781d9fa..720c19616 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -2,6 +2,7 @@ extends = nrf52_base board = rak4631 board_check = true +board_build.ldscript = boards/nrf52840_s140_v6.ld extra_scripts = ${nrf52_base.extra_scripts} post:variants/rak4631/fix_bsec_lib.py build_flags = ${nrf52_base.build_flags} @@ -48,6 +49,7 @@ build_flags = -D MAX_NEIGHBOURS=50 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 +; -D POWERSAVING_DEBUG=1 build_src_filter = ${rak4631.build_src_filter} + +<../examples/simple_repeater> @@ -219,10 +221,11 @@ 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 +; -D POWERSAVING_DEBUG=1 build_src_filter = ${rak4631.build_src_filter} + +<../examples/companion_radio/*.cpp> diff --git a/variants/rak_wismesh_tag/RAKWismeshTagBoard.h b/variants/rak_wismesh_tag/RAKWismeshTagBoard.h index daa90d746..9368d17e8 100644 --- a/variants/rak_wismesh_tag/RAKWismeshTagBoard.h +++ b/variants/rak_wismesh_tag/RAKWismeshTagBoard.h @@ -40,7 +40,9 @@ public: return "RAK WisMesh Tag"; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); + #ifdef BUZZER_EN digitalWrite(BUZZER_EN, LOW); #endif @@ -68,7 +70,5 @@ public: // configure button press to wake up when in powered off state nrf_gpio_cfg_sense_input(digitalPinToInterrupt(BUTTON_PIN), NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); #endif - - NRF52Board::powerOff(); } }; diff --git a/variants/rak_wismesh_tag/platformio.ini b/variants/rak_wismesh_tag/platformio.ini index d61401871..edfe1b70f 100644 --- a/variants/rak_wismesh_tag/platformio.ini +++ b/variants/rak_wismesh_tag/platformio.ini @@ -2,6 +2,7 @@ extends = nrf52_base board = rak4631 board_check = true +board_build.ldscript = boards/nrf52840_s140_v6.ld build_flags = ${nrf52_base.build_flags} ${sensor_base.build_flags} -I variants/rak_wismesh_tag diff --git a/variants/sensecap_solar/SenseCapSolarBoard.h b/variants/sensecap_solar/SenseCapSolarBoard.h index 5a8861c29..ad0b62ab1 100644 --- a/variants/sensecap_solar/SenseCapSolarBoard.h +++ b/variants/sensecap_solar/SenseCapSolarBoard.h @@ -37,7 +37,9 @@ public: return "Seeed SenseCap Solar"; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); + digitalWrite(LED_WHITE, LOW); digitalWrite(LED_BLUE, LOW); @@ -50,6 +52,10 @@ public: // Keep pull-up enabled in system-off so the wake line doesn't float low. nrf_gpio_cfg_sense_input(digitalPinToInterrupt(g_ADigitalPinMap[PIN_BUTTON1]), NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); #endif + } + + void powerOff() override { + shutdownPeripherals(); #ifdef NRF52_POWER_MANAGEMENT initiateShutdown(SHUTDOWN_REASON_USER); diff --git a/variants/station_g3_esp32/StationG3Board.cpp b/variants/station_g3_esp32/StationG3Board.cpp index 277c2b8b7..ddcb6159e 100644 --- a/variants/station_g3_esp32/StationG3Board.cpp +++ b/variants/station_g3_esp32/StationG3Board.cpp @@ -1,6 +1,8 @@ #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); @@ -9,8 +11,6 @@ void StationG3Board::powerOff() { #ifdef P_PRIMARY_LNA_EN 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 b4c5838c8..ca22ff233 100644 --- a/variants/station_g3_esp32/StationG3Board.h +++ b/variants/station_g3_esp32/StationG3Board.h @@ -53,7 +53,7 @@ public: 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 ba8bf0c1d..a08fc4e4f 100644 --- a/variants/t1000-e/T1000eBoard.h +++ b/variants/t1000-e/T1000eBoard.h @@ -49,7 +49,9 @@ public: return 0; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); + #ifdef HAS_GPS digitalWrite(GPS_VRTC_EN, LOW); digitalWrite(GPS_RESET, LOW); @@ -83,7 +85,5 @@ public: #ifdef BUTTON_PIN nrf_gpio_cfg_sense_input(BUTTON_PIN, NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_SENSE_HIGH); #endif - - NRF52Board::powerOff(); } }; diff --git a/variants/t1000-e/platformio.ini b/variants/t1000-e/platformio.ini index 8456dc915..42dff1aae 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/thinknode_m1/ThinkNodeM1Board.h b/variants/thinknode_m1/ThinkNodeM1Board.h index 94224d9c2..e5af3690d 100644 --- a/variants/thinknode_m1/ThinkNodeM1Board.h +++ b/variants/thinknode_m1/ThinkNodeM1Board.h @@ -32,14 +32,12 @@ public: 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 digitalWrite(P_LORA_TX_LED, LOW); #endif - - // power off board - NRF52Board::powerOff(); } }; diff --git a/variants/thinknode_m3/ThinkNodeM3Board.h b/variants/thinknode_m3/ThinkNodeM3Board.h index 9e8f49894..496ee2e0e 100644 --- a/variants/thinknode_m3/ThinkNodeM3Board.h +++ b/variants/thinknode_m3/ThinkNodeM3Board.h @@ -42,13 +42,12 @@ public: 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 934794c9a..3203ab718 100644 --- a/variants/thinknode_m6/ThinkNodeM6Board.h +++ b/variants/thinknode_m6/ThinkNodeM6Board.h @@ -65,14 +65,12 @@ public: 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 digitalWrite(P_LORA_TX_LED, LOW); #endif - - // power off board - NRF52Board::powerOff(); } }; diff --git a/variants/thinknode_m6/platformio.ini b/variants/thinknode_m6/platformio.ini index ad7e6902c..4ed0848d1 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 diff --git a/variants/wio-tracker-l1/WioTrackerL1Board.h b/variants/wio-tracker-l1/WioTrackerL1Board.h index 99e852bb7..9dbe85642 100644 --- a/variants/wio-tracker-l1/WioTrackerL1Board.h +++ b/variants/wio-tracker-l1/WioTrackerL1Board.h @@ -33,8 +33,4 @@ public: const char* getManufacturerName() const override { return "Seeed Wio Tracker L1"; } - - void powerOff() override { - NRF52Board::powerOff(); - } }; diff --git a/variants/wio-tracker-l1/platformio.ini b/variants/wio-tracker-l1/platformio.ini index 19139c289..6c39ae6d3 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} diff --git a/variants/wio_wm1110/platformio.ini b/variants/wio_wm1110/platformio.ini index a7eac916b..3a040c592 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 @@ -84,8 +84,8 @@ build_src_filter = ${wio_wm1110.build_src_filter} lib_deps = ${wio_wm1110.lib_deps} densaugeo/base64 @ ~1.4.0 - -[env:wio_wm1110_kiss_modem] -extends = wio_wm1110 -build_src_filter = ${wio_wm1110.build_src_filter} - +<../examples/kiss_modem/> + +[env:wio_wm1110_kiss_modem] +extends = wio_wm1110 +build_src_filter = ${wio_wm1110.build_src_filter} + +<../examples/kiss_modem/> diff --git a/variants/xiao_nrf52/XiaoNrf52Board.h b/variants/xiao_nrf52/XiaoNrf52Board.h index e71e7f651..94c478e2f 100644 --- a/variants/xiao_nrf52/XiaoNrf52Board.h +++ b/variants/xiao_nrf52/XiaoNrf52Board.h @@ -35,7 +35,9 @@ public: 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 @@ -49,8 +51,6 @@ public: // configure button press to wake up when in powered off state nrf_gpio_cfg_sense_input(digitalPinToInterrupt(g_ADigitalPinMap[PIN_USER_BTN]), NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); #endif - - NRF52Board::powerOff(); } }; diff --git a/variants/xiao_nrf52/platformio.ini b/variants/xiao_nrf52/platformio.ini index b40ebbc36..df05b70fc 100644 --- a/variants/xiao_nrf52/platformio.ini +++ b/variants/xiao_nrf52/platformio.ini @@ -9,6 +9,7 @@ build_flags = ${nrf52_base.build_flags} -I variants/xiao_nrf52 -UENV_INCLUDE_GPS -D NRF52_PLATFORM +; -D NRF52_POWER_MANAGEMENT -D XIAO_NRF52 -D USE_SX1262 -D RADIO_CLASS=CustomSX1262 @@ -110,6 +111,24 @@ build_flags = build_src_filter = ${Xiao_nrf52.build_src_filter} +<../examples/simple_room_server/*.cpp> +[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> + [env:Xiao_nrf52_kiss_modem] extends = Xiao_nrf52 build_flags = diff --git a/variants/xiao_s3_wio/platformio.ini b/variants/xiao_s3_wio/platformio.ini index 5a2ef1061..772bbadc6 100644 --- a/variants/xiao_s3_wio/platformio.ini +++ b/variants/xiao_s3_wio/platformio.ini @@ -6,8 +6,8 @@ board_build.mcu = esp32s3 build_flags = ${esp32_base.build_flags} ${sensor_base.build_flags} -I variants/xiao_s3_wio - -UENV_INCLUDE_GPS -D SEEED_XIAO_S3 + -D PIN_VBAT_READ=D1 -D P_LORA_DIO_1=39 -D P_LORA_NSS=41 -D P_LORA_RESET=42 ; RADIOLIB_NC @@ -29,6 +29,10 @@ build_flags = ${esp32_base.build_flags} -D WRAPPER_CLASS=CustomSX1262Wrapper -D LORA_TX_POWER=22 -D SX126X_RX_BOOSTED_GAIN=1 + -D PIN_GPS_RX=D6 + -D PIN_GPS_TX=D7 + -D PIN_GPS_EN=D0 + -D PIN_GPS_RESET=D2 build_src_filter = ${esp32_base.build_src_filter} +<../variants/xiao_s3_wio> + diff --git a/variants/xiao_s3_wio/target.cpp b/variants/xiao_s3_wio/target.cpp index 8a975959f..9824ed942 100644 --- a/variants/xiao_s3_wio/target.cpp +++ b/variants/xiao_s3_wio/target.cpp @@ -14,7 +14,14 @@ WRAPPER_CLASS radio_driver(radio, board); ESP32RTCClock fallback_clock; AutoDiscoverRTCClock rtc_clock(fallback_clock); -EnvironmentSensorManager sensors; + +#if ENV_INCLUDE_GPS + #include + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock, GPS_RESET, GPS_EN); + EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); +#else + EnvironmentSensorManager sensors; +#endif #ifdef DISPLAY_CLASS DISPLAY_CLASS display; From 7c574c62d7f3ae1453d72afdf0179b33edc26c6b Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sat, 17 Oct 2026 11:57:03 +0700 Subject: [PATCH 11/34] Custom GPS PS for meshsolar, tracker, x1 and T1000e --- variants/heltec_mesh_solar/target.cpp | 73 +++++++++++++++++--- variants/heltec_mesh_solar/target.h | 1 + variants/heltec_tracker/target.cpp | 73 +++++++++++++++++--- variants/heltec_tracker/target.h | 1 + variants/meshtracker_x1/MeshTrackerX1Board.h | 6 +- variants/meshtracker_x1/target.cpp | 73 +++++++++++++++++--- variants/meshtracker_x1/target.h | 1 + variants/t1000-e/target.cpp | 72 ++++++++++++++++--- variants/t1000-e/target.h | 1 + 9 files changed, 259 insertions(+), 42 deletions(-) diff --git a/variants/heltec_mesh_solar/target.cpp b/variants/heltec_mesh_solar/target.cpp index 94e2684c1..aef713f30 100644 --- a/variants/heltec_mesh_solar/target.cpp +++ b/variants/heltec_mesh_solar/target.cpp @@ -31,14 +31,30 @@ mesh::LocalIdentity radio_new_identity() { void SolarSensorManager::start_gps() { if (!gps_active) { - gps_active = true; + if (_location->isPowerSavingEnabled()) { + gps_wake = true; // gps_active is true + _location->syncTime(); // Clear GPS data and force sync time + _location->setNextSleep(); // Next time to off + } else { + gps_active = true; + gps_wake = true; + } + _location->begin(); } } void SolarSensorManager::stop_gps() { if (gps_active) { - gps_active = false; + if (_location->isPowerSavingEnabled()) { + gps_wake = false; // gps_active is unchanged (true) even the GPS sleep (e.g: off) + _location->stopTimeSync(); // Stop time sync + _location->setNextWake(); // Next time to on + } else { + gps_active = false; + gps_wake = false; // When GPS is off, wake is false to be sure + } + _location->stop(); } } @@ -68,16 +84,51 @@ bool SolarSensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& void SolarSensorManager::loop() { static long next_gps_update = 0; - _location->loop(); + // PowerSaving + if (_location->isPowerSavingEnabled() && gps_detected && gps_active) { + // Handle change in PowerSaving mode + _location->updatePowerSavingSettings(gps_wake); - if (millis() > next_gps_update) { - if (_location->isValid()) { - node_lat = ((double)_location->getLatitude())/1000000.; - node_lon = ((double)_location->getLongitude())/1000000.; - node_altitude = ((double)_location->getAltitude()) / 1000.0; - MESH_DEBUG_PRINTLN("lat %f lon %f", node_lat, node_lon); + if (gps_wake) { + // GPS is awake: check whether it should sleep + if ((int32_t)(millis() - _location->getNextSleep()) >= 0) { + POWERSAVING_DEBUG_PRINTLN("GPS wake timeout. Enter sleep"); + stop_gps(); + } else if (!_location->waitingTimeSync()) { + POWERSAVING_DEBUG_PRINTLN("GPS set. Enter sleep early"); + stop_gps(); + } + } else { + // GPS is asleep: check whether it should wake + if ((int32_t)(millis() - _location->getNextWake()) >= 0) { + POWERSAVING_DEBUG_PRINTLN("GPS sleep timeout. Wakeup."); + start_gps(); + } else if (_location->waitingTimeSync()) { + POWERSAVING_DEBUG_PRINTLN("CLI gps sync. Wakeup"); + start_gps(); + } } - next_gps_update = millis() + 1000; + } + + if ((!_location->isPowerSavingEnabled() && gps_active) || (_location->isPowerSavingEnabled() && gps_wake)) { + _location->loop(); + } + + if ((int32_t)(millis() - next_gps_update) >= 0) { + if ((!_location->isPowerSavingEnabled() && gps_active) || (_location->isPowerSavingEnabled() && gps_wake)) { + if (_location->isValid()) { + node_lat = ((double)_location->getLatitude()) / 1000000.; + node_lon = ((double)_location->getLongitude()) / 1000000.; + MESH_DEBUG_PRINTLN("lat %f lon %f", node_lat, node_lon); + node_altitude = ((double)_location->getAltitude()) / 1000.0; + MESH_DEBUG_PRINTLN("lat %f lon %f alt %f", node_lat, node_lon, node_altitude); + } + + // In powersaving mode, GPS is on and off. Only update data when GPS is on + if (_location->isPowerSavingEnabled()) next_gps_update = millis() + 1000; + } + + if (!_location->isPowerSavingEnabled()) next_gps_update = millis() + 1000; } } @@ -99,8 +150,10 @@ const char* SolarSensorManager::getSettingValue(int i) const { bool SolarSensorManager::setSettingValue(const char* name, const char* value) { if (gps_detected && strcmp(name, "gps") == 0) { if (strcmp(value, "0") == 0) { + gps_active = false; // Disabled by CLI or App stop_gps(); } else { + gps_active = true; // Enabled by CLI or App start_gps(); } return true; diff --git a/variants/heltec_mesh_solar/target.h b/variants/heltec_mesh_solar/target.h index 8e13a9601..f80d22473 100644 --- a/variants/heltec_mesh_solar/target.h +++ b/variants/heltec_mesh_solar/target.h @@ -16,6 +16,7 @@ class SolarSensorManager : public SensorManager { bool gps_active = false; bool gps_detected = false; + bool gps_wake = false; // In PowerSaving, gps_active can have gps_wake true or false LocationProvider* _location; void start_gps(); diff --git a/variants/heltec_tracker/target.cpp b/variants/heltec_tracker/target.cpp index f32c41ff4..67effdb0d 100644 --- a/variants/heltec_tracker/target.cpp +++ b/variants/heltec_tracker/target.cpp @@ -45,14 +45,31 @@ mesh::LocalIdentity radio_new_identity() { void HWTSensorManager::start_gps() { if (!gps_active) { _location->begin(); // Claims periph_power via RefCountedDigitalPin - gps_active = true; + + if (_location->isPowerSavingEnabled()) { + gps_wake = true; // gps_active is unchanged (true for GPS sleep, false for GPS off) + _location->syncTime(); // Clear GPS data and force sync time + _location->setNextSleep(); // Next time to off + } else { + gps_active = true; + gps_wake = true; + } + Serial1.println("$CFGSYS,h35155*68"); // Configure GPS for all constellations } } void HWTSensorManager::stop_gps() { if (gps_active) { - gps_active = false; + if (_location->isPowerSavingEnabled()) { + gps_wake = false; // gps_active is unchanged (true) even the GPS sleep (e.g: off) + _location->stopTimeSync(); // Stop time sync + _location->setNextWake(); // Next time to on + } else { + gps_active = false; + gps_wake = false; // When GPS is off, wake is false to be sure + } + _location->stop(); // Releases periph_power via RefCountedDigitalPin } } @@ -73,16 +90,50 @@ bool HWTSensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& t void HWTSensorManager::loop() { static long next_gps_update = 0; - _location->loop(); + // PowerSaving + if (_location->isPowerSavingEnabled() && gps_active) { + // Handle change in PowerSaving mode + _location->updatePowerSavingSettings(gps_wake); - if (millis() > next_gps_update) { - if (gps_active && _location->isValid()) { - node_lat = ((double)_location->getLatitude())/1000000.; - node_lon = ((double)_location->getLongitude())/1000000.; - node_altitude = ((double)_location->getAltitude()) / 1000.0; - MESH_DEBUG_PRINTLN("lat %f lon %f", node_lat, node_lon); + if (gps_wake) { + // GPS is awake: check whether it should sleep + if ((int32_t)(millis() - _location->getNextSleep()) >= 0) { + POWERSAVING_DEBUG_PRINTLN("GPS wake timeout. Enter sleep"); + stop_gps(); + } else if (!_location->waitingTimeSync()) { + POWERSAVING_DEBUG_PRINTLN("GPS set. Enter sleep early"); + stop_gps(); + } + } else { + // GPS is asleep: check whether it should wake + if ((int32_t)(millis() - _location->getNextWake()) >= 0) { + POWERSAVING_DEBUG_PRINTLN("GPS sleep timeout. Wakeup."); + start_gps(); + } else if (_location->waitingTimeSync()) { + POWERSAVING_DEBUG_PRINTLN("CLI gps sync. Wakeup"); + start_gps(); + } } - next_gps_update = millis() + 1000; + } + + if ((!_location->isPowerSavingEnabled() && gps_active) || (_location->isPowerSavingEnabled() && gps_wake)) { + _location->loop(); + } + + if ((int32_t)(millis() - next_gps_update) >= 0) { + if ((!_location->isPowerSavingEnabled() && gps_active) || (_location->isPowerSavingEnabled() && gps_wake)) { + if (gps_active && _location->isValid()) { + node_lat = ((double)_location->getLatitude()) / 1000000.; + node_lon = ((double)_location->getLongitude()) / 1000000.; + node_altitude = ((double)_location->getAltitude()) / 1000.0; + MESH_DEBUG_PRINTLN("lat %f lon %f alt %f", node_lat, node_lon, node_altitude); + } + + // In powersaving mode, GPS is on and off. Only update data when GPS is on + if (_location->isPowerSavingEnabled()) next_gps_update = millis() + 1000; + } + + if (!_location->isPowerSavingEnabled()) next_gps_update = millis() + 1000; } } @@ -100,8 +151,10 @@ const char* HWTSensorManager::getSettingValue(int i) const { bool HWTSensorManager::setSettingValue(const char* name, const char* value) { if (strcmp(name, "gps") == 0) { if (strcmp(value, "0") == 0) { + gps_active = false; // Disabled by CLI or App stop_gps(); } else { + gps_active = true; // Enabled by CLI or App start_gps(); } return true; diff --git a/variants/heltec_tracker/target.h b/variants/heltec_tracker/target.h index 2931eda81..0fe3d0251 100644 --- a/variants/heltec_tracker/target.h +++ b/variants/heltec_tracker/target.h @@ -15,6 +15,7 @@ class HWTSensorManager : public SensorManager { bool gps_active = false; + bool gps_wake = false; // In PowerSaving, gps_active can have gps_wake true or false LocationProvider * _location; void start_gps(); diff --git a/variants/meshtracker_x1/MeshTrackerX1Board.h b/variants/meshtracker_x1/MeshTrackerX1Board.h index 51f022e0b..e153dd7fb 100644 --- a/variants/meshtracker_x1/MeshTrackerX1Board.h +++ b/variants/meshtracker_x1/MeshTrackerX1Board.h @@ -46,7 +46,9 @@ public: return 0; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); + #ifdef HAS_GPS digitalWrite(GPS_VRTC_EN, LOW); digitalWrite(GPS_RESET, LOW); @@ -81,7 +83,5 @@ public: #ifdef BUTTON_PIN 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/meshtracker_x1/target.cpp b/variants/meshtracker_x1/target.cpp index 5b90d6609..6dc2bc0df 100644 --- a/variants/meshtracker_x1/target.cpp +++ b/variants/meshtracker_x1/target.cpp @@ -27,7 +27,15 @@ mesh::LocalIdentity radio_new_identity() { } void MeshTrackerX1SensorManager::start_gps() { - gps_active = true; + if (_nmea->isPowerSavingEnabled()) { + gps_wake = true; // gps_active is unchanged (true for GPS sleep, false for GPS off) + _nmea->syncTime(); // Clear GPS data and force sync time + _nmea->setNextSleep(); // Next time to off + } else { + gps_active = true; + gps_wake = true; + } + digitalWrite(GPS_EN, HIGH); delay(10); digitalWrite(GPS_RTC_INT, HIGH); @@ -36,7 +44,15 @@ void MeshTrackerX1SensorManager::start_gps() { } void MeshTrackerX1SensorManager::stop_gps() { - gps_active = false; + if (_nmea->isPowerSavingEnabled()) { + gps_wake = false; // gps_active is unchanged (true) even the GPS sleep (e.g: off) + _nmea->stopTimeSync(); // Stop time sync + _nmea->setNextWake(); // Next time to on + } else { + gps_active = false; + gps_wake = false; // When GPS is off, wake is false to be sure + } + digitalWrite(GPS_VRTC_EN, HIGH); // keep GPS RTC alive for faster fix on wake digitalWrite(GPS_RTC_INT, LOW); // make sure this is LOW so we can pulse it to wake airohaEnterSleep(_nmea); @@ -77,16 +93,51 @@ bool MeshTrackerX1SensorManager::querySensors(uint8_t requester_permissions, Cay void MeshTrackerX1SensorManager::loop() { static long next_gps_update = 0; + // PowerSaving + if (_nmea->isPowerSavingEnabled() && gps_active) { + // Handle change in PowerSaving mode + _nmea->updatePowerSavingSettings(gps_wake); - _nmea->loop(); - - if (millis() > next_gps_update) { - if (gps_active && _nmea->isValid()) { - node_lat = ((double)_nmea->getLatitude())/1000000.; - node_lon = ((double)_nmea->getLongitude())/1000000.; - node_altitude = ((double)_nmea->getAltitude()) / 1000.0; + if (gps_wake) { + // GPS is awake: check whether it should sleep + if ((int32_t)(millis() - _nmea->getNextSleep()) >= 0) { + POWERSAVING_DEBUG_PRINTLN("GPS wake timeout. Enter sleep"); + stop_gps(); + } else if (!_nmea->waitingTimeSync()) { + POWERSAVING_DEBUG_PRINTLN("GPS set. Enter sleep early"); + stop_gps(); + } + } else { + // GPS is asleep: check whether it should wake + if ((int32_t)(millis() - _nmea->getNextWake()) >= 0) { + POWERSAVING_DEBUG_PRINTLN("GPS sleep timeout. Wakeup."); + start_gps(); + } else if (_nmea->waitingTimeSync()) { + POWERSAVING_DEBUG_PRINTLN("CLI gps sync. Wakeup"); + start_gps(); + } } - next_gps_update = millis() + 1000; + } + + if ((!_nmea->isPowerSavingEnabled() && gps_active) || (_nmea->isPowerSavingEnabled() && gps_wake)) { + _nmea->loop(); + } + + if ((int32_t)(millis() - next_gps_update) >= 0) { + if ((!_nmea->isPowerSavingEnabled() && gps_active) || (_nmea->isPowerSavingEnabled() && gps_wake)) { + if (gps_active && _nmea->isValid()) { + node_lat = ((double)_nmea->getLatitude()) / 1000000.; + node_lon = ((double)_nmea->getLongitude()) / 1000000.; + MESH_DEBUG_PRINTLN("lat %f lon %f", node_lat, node_lon); + node_altitude = ((double)_nmea->getAltitude()) / 1000.0; + MESH_DEBUG_PRINTLN("lat %f lon %f alt %f", node_lat, node_lon, node_altitude); + } + + // In powersaving mode, GPS is on and off. Only update data when GPS is on + if (_nmea->isPowerSavingEnabled()) next_gps_update = millis() + 1000; + } + + if (!_nmea->isPowerSavingEnabled()) next_gps_update = millis() + 1000; } } @@ -104,8 +155,10 @@ const char* MeshTrackerX1SensorManager::getSettingValue(int i) const { bool MeshTrackerX1SensorManager::setSettingValue(const char* name, const char* value) { if (strcmp(name, "gps") == 0) { if (strcmp(value, "0") == 0) { + gps_active = false; // Disabled by CLI or App stop_gps(); } else { + gps_active = true; // Enabled by CLI or App start_gps(); } return true; diff --git a/variants/meshtracker_x1/target.h b/variants/meshtracker_x1/target.h index 9741efb6a..6138a7ee5 100644 --- a/variants/meshtracker_x1/target.h +++ b/variants/meshtracker_x1/target.h @@ -15,6 +15,7 @@ class MeshTrackerX1SensorManager: public SensorManager { bool gps_active = false; + bool gps_wake = false; // In PowerSaving, gps_active can have gps_wake true or false bool baro_ok = false; Adafruit_SPA06_003 spa06; LocationProvider * _nmea; diff --git a/variants/t1000-e/target.cpp b/variants/t1000-e/target.cpp index 70a64359f..cb6194d34 100644 --- a/variants/t1000-e/target.cpp +++ b/variants/t1000-e/target.cpp @@ -81,7 +81,15 @@ mesh::LocalIdentity radio_new_identity() { } void T1000SensorManager::start_gps() { - gps_active = true; + if (_nmea->isPowerSavingEnabled()) { + gps_wake = true; // gps_active is unchanged (true for GPS sleep, false for GPS off) + _nmea->syncTime(); // Clear GPS data and force sync time + _nmea->setNextSleep(); // Next time to off + } else { + gps_active = true; + gps_wake = true; + } + digitalWrite(GPS_EN, HIGH); delay(10); digitalWrite(GPS_RTC_INT, HIGH); @@ -90,7 +98,15 @@ void T1000SensorManager::start_gps() { } void T1000SensorManager::stop_gps() { - gps_active = false; + if (_nmea->isPowerSavingEnabled()) { + gps_wake = false; // gps_active is unchanged (true) even the GPS sleep (e.g: off) + _nmea->stopTimeSync(); // Stop time sync + _nmea->setNextWake(); // Next time to on + } else { + gps_active = false; + gps_wake = false; // When GPS is off, wake is false to be sure + } + digitalWrite(GPS_VRTC_EN, HIGH); // keep GPS RTC alive for faster fix on wake digitalWrite(GPS_RTC_INT, LOW); // make sure this is LOW so we can pulse it to wake airohaEnterSleep(_nmea); // send command to put the GPS into RTC backup sleep @@ -122,15 +138,51 @@ bool T1000SensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& void T1000SensorManager::loop() { static long next_gps_update = 0; - _nmea->loop(); + // PowerSaving + if (_nmea->isPowerSavingEnabled() && gps_active) { + // Handle change in PowerSaving mode + _nmea->updatePowerSavingSettings(gps_wake); - if (millis() > next_gps_update) { - if (gps_active && _nmea->isValid()) { - node_lat = ((double)_nmea->getLatitude())/1000000.; - node_lon = ((double)_nmea->getLongitude())/1000000.; - node_altitude = ((double)_nmea->getAltitude()) / 1000.0; + if (gps_wake) { + // GPS is awake: check whether it should sleep + if ((int32_t)(millis() - _nmea->getNextSleep()) >= 0) { + POWERSAVING_DEBUG_PRINTLN("GPS wake timeout. Enter sleep"); + stop_gps(); + } else if (!_nmea->waitingTimeSync()) { + POWERSAVING_DEBUG_PRINTLN("GPS set. Enter sleep early"); + stop_gps(); + } + } else { + // GPS is asleep: check whether it should wake + if ((int32_t)(millis() - _nmea->getNextWake()) >= 0) { + POWERSAVING_DEBUG_PRINTLN("GPS sleep timeout. Wakeup."); + start_gps(); + } else if (_nmea->waitingTimeSync()) { + POWERSAVING_DEBUG_PRINTLN("CLI gps sync. Wakeup"); + start_gps(); + } } - next_gps_update = millis() + 1000; + } + + if ((!_nmea->isPowerSavingEnabled() && gps_active) || (_nmea->isPowerSavingEnabled() && gps_wake)) { + _nmea->loop(); + } + + if ((int32_t)(millis() - next_gps_update) >= 0) { + if ((!_nmea->isPowerSavingEnabled() && gps_active) || (_nmea->isPowerSavingEnabled() && gps_wake)) { + if (gps_active && _nmea->isValid()) { + node_lat = ((double)_nmea->getLatitude()) / 1000000.; + node_lon = ((double)_nmea->getLongitude()) / 1000000.; + MESH_DEBUG_PRINTLN("lat %f lon %f", node_lat, node_lon); + node_altitude = ((double)_nmea->getAltitude()) / 1000.0; + MESH_DEBUG_PRINTLN("lat %f lon %f alt %f", node_lat, node_lon, node_altitude); + } + + // In powersaving mode, GPS is on and off. Only update data when GPS is on + if (_nmea->isPowerSavingEnabled()) next_gps_update = millis() + 1000; + } + + if (!_nmea->isPowerSavingEnabled()) next_gps_update = millis() + 1000; } } @@ -148,8 +200,10 @@ const char* T1000SensorManager::getSettingValue(int i) const { bool T1000SensorManager::setSettingValue(const char* name, const char* value) { if (strcmp(name, "gps") == 0) { if (strcmp(value, "0") == 0) { + gps_active = false; // Disabled by CLI or App stop_gps(); } else { + gps_active = true; // Enabled by CLI or App start_gps(); } return true; diff --git a/variants/t1000-e/target.h b/variants/t1000-e/target.h index db003cc5f..cd91ceec9 100644 --- a/variants/t1000-e/target.h +++ b/variants/t1000-e/target.h @@ -14,6 +14,7 @@ class T1000SensorManager: public SensorManager { bool gps_active = false; + bool gps_wake = false; // In PowerSaving, gps_active can have gps_wake true or false LocationProvider * _nmea; void start_gps(); From 41392021f57affa6f5caac812084ca0b9c6edea4 Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sat, 17 Oct 2026 11:59:13 +0700 Subject: [PATCH 12/34] Fixed powerOff and shutdownPeripherals for TBeam1W --- variants/lilygo_tbeam_1w/TBeam1WBoard.cpp | 6 +++--- variants/lilygo_tbeam_1w/TBeam1WBoard.h | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp index e04199554..27cddac83 100644 --- a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp +++ b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp @@ -82,7 +82,9 @@ 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; @@ -98,8 +100,6 @@ void TBeam1WBoard::powerOff() { radio_powered = false; 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 429b7bd1c..33af410a8 100644 --- a/variants/lilygo_tbeam_1w/TBeam1WBoard.h +++ b/variants/lilygo_tbeam_1w/TBeam1WBoard.h @@ -70,7 +70,7 @@ public: 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; From fe44cb432634b2cd97b9a470d3f4ca7bd6b4b269 Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sat, 17 Oct 2026 12:04:21 +0700 Subject: [PATCH 13/34] Custom GPS, Added BME680_BSEC and Keep time across reset for RAK3401 --- variants/rak3401/fix_bsec_lib.py | 15 +++++++++++++++ variants/rak3401/platformio.ini | 19 +++++++++++++++++++ 2 files changed, 34 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 000000000..605ff1187 --- /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]) \ No newline at end of file diff --git a/variants/rak3401/platformio.ini b/variants/rak3401/platformio.ini index d157e4b1d..eddadd031 100644 --- a/variants/rak3401/platformio.ini +++ b/variants/rak3401/platformio.ini @@ -2,6 +2,9 @@ extends = nrf52_base board = rak3401 board_check = true +board_build.ldscript = boards/nrf52840_s140_v6.ld +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 @@ -13,6 +16,11 @@ build_flags = ${nrf52_base.build_flags} -D SX126X_CURRENT_LIMIT=140 -D SX126X_RX_BOOSTED_GAIN=1 -D SX126X_REGISTER_PATCH=1 ; Patch register 0x8B5 for improved RX with SKY66122 FEM + -D FORCE_GPS_ALIVE ; on RAK3401, gpsResetPin ends up = WB_IO2 (kills 3V3_S rail) or + ; pin 4 (= SX126X_RESET, holds radio in reset). stop_gps() must not run. + -D PIN_GPS_EN=-1 ; -1 = no dedicated GPS enable pin (power is the shared 3V3_S rail), same as rak4631 + -UENV_INCLUDE_BME680 + -D ENV_INCLUDE_BME680_BSEC=1 build_src_filter = ${nrf52_base.build_src_filter} +<../variants/rak3401> + @@ -40,6 +48,17 @@ build_src_filter = ${rak3401.build_src_filter} + +<../examples/simple_repeater> +[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} + + + [env:RAK_3401_room_server] extends = rak3401 build_flags = From a9845842d15b8667999f04be3382cae407dfb27a Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sat, 17 Oct 2026 12:08:24 +0700 Subject: [PATCH 14/34] Keep time across reset for NRF52 --- src/helpers/ArduinoHelpers.cpp | 6 +++++ src/helpers/ArduinoHelpers.h | 42 ++++++++++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 src/helpers/ArduinoHelpers.cpp diff --git a/src/helpers/ArduinoHelpers.cpp b/src/helpers/ArduinoHelpers.cpp new file mode 100644 index 000000000..feb77a79c --- /dev/null +++ b/src/helpers/ArduinoHelpers.cpp @@ -0,0 +1,6 @@ +#include + +extern "C" { + __attribute__((section(".persistent_magic"))) uint32_t persistent_magic; + __attribute__((section(".persistent_data"))) uint32_t persistent_time; +} \ No newline at end of file diff --git a/src/helpers/ArduinoHelpers.h b/src/helpers/ArduinoHelpers.h index 97596daa3..9b50b98ca 100644 --- a/src/helpers/ArduinoHelpers.h +++ b/src/helpers/ArduinoHelpers.h @@ -3,19 +3,57 @@ #include #include +#ifdef NRF52_PLATFORM +#define CLOCK_MAGIC_NUM 0xAA55CC33 +#define RTC_TIME_MIN 1772323200 // 1 Mar 2026 + +extern uint32_t persistent_magic; +extern uint32_t persistent_time; +#endif + class VolatileRTCClock : public mesh::RTCClock { uint32_t base_time; uint64_t accumulator; unsigned long 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_magic = CLOCK_MAGIC_NUM; + persistent_time = time; +#endif + } void tick() override { unsigned long now = millis(); accumulator += (now - prev_millis); prev_millis = now; + +#ifdef NRF52_PLATFORM + persistent_magic = CLOCK_MAGIC_NUM; + persistent_time = getCurrentTime(); +#endif } }; From ad0436c61043ed33b7ea864e815981c4ccd89841 Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sat, 17 Oct 2026 14:50:43 +0700 Subject: [PATCH 15/34] UI changes for PowerSaving variant --- examples/companion_radio/ui-new/UITask.cpp | 35 ++++++++++++++++++++++ examples/simple_repeater/UITask.cpp | 14 ++++++++- examples/simple_repeater/UITask.h | 5 +++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index bc25de79c..f527baf58 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -123,6 +123,14 @@ class HomeScreen : public UIScreen { bool discovery_disp_names = true; // by default desplay names if available (removes SNR_O) #endif + // PowerSaving variant + // Uptime tracking + // Stores the last 32-bit millis() value used to calculate elapsed time. + // uint32_t naturally handles the ~49.7-day millis() rollover. + uint32_t uptime_last_millis = millis(); + // 64-bit accumulated uptime in milliseconds. + // Allows uptime to continue beyond the 49.7-day millis() rollover. + uint64_t uptime_millis = 0; void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) { // Convert millivolts to percentage #ifndef BATT_MIN_MILLIVOLTS @@ -174,6 +182,33 @@ class HomeScreen : public UIScreen { display.drawXbm(iconX - (charging ? 18 : 9), iconY + 1, muted_icon, 8, 8); } #endif + + // PowerSaving variant + // Uptime + uint32_t now = millis(); + uptime_millis += (uint32_t)(now - uptime_last_millis); + uptime_last_millis = now; + + uint64_t uptimeMinutes = uptime_millis / 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.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); } CayenneLPP sensors_lpp; diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index e0b6a41df..a60bea722 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -29,7 +29,9 @@ static const uint8_t meshcore_logo [] PROGMEM = { 0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfc, 0x3c, 0x0e, 0x1f, 0xf8, 0xff, 0xf8, 0x70, 0x3c, 0x7f, 0xf8, }; -void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version) { +// PowerSaving variant +// Added board for battery display +void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version, mesh::MainBoard* board) { _prevBtnState = HIGH; _auto_off = millis() + AUTO_OFF_MILLIS; _started_at = millis(); @@ -106,6 +108,16 @@ void UITask::renderCurrScreen() { _display->setCursor(0, 30); sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); _display->print(tmp); + + // Battery + _display->setCursor(0, 40); + sprintf(tmp, "BAT: %.2fV", _board->getBattMilliVolts() / 1000.0); + _display->print(tmp); + + // PowerSaving + _display->setCursor(0, 50); + sprintf(tmp, "PowerSaving: %s", _node_prefs->powersaving_enabled? "ON" : "off"); + _display->print(tmp); } } diff --git a/examples/simple_repeater/UITask.h b/examples/simple_repeater/UITask.h index d8e3ce1d2..b1c63e397 100644 --- a/examples/simple_repeater/UITask.h +++ b/examples/simple_repeater/UITask.h @@ -16,7 +16,10 @@ class UITask { void renderCurrScreen(); public: UITask(mesh::MainBoard& board, DisplayDriver& display) : _board(&board), _display(&display) { _next_read = _next_refresh = 0; } - void begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version); + + // PowerSaving variant + // Added board for battery display + void begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version, mesh::MainBoard* board); void loop(); }; \ No newline at end of file From eb1139ee65813d766474a2987078bf5c46c399ee Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sat, 17 Oct 2026 17:53:44 +0700 Subject: [PATCH 16/34] Removed outpath, updated docs for rxps, powersaving --- docs/cli_commands.md | 207 +++++-------------------------------------- 1 file changed, 22 insertions(+), 185 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 5963acdcf..1a1f26b0d 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -277,39 +277,6 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- -#### View or change RX duty-cycle power saving -**Usage:** -- `get radio.rxps` -- `set radio.rxps off` -- `set radio.rxps on` -- `set radio.rxps conservative` -- `set radio.rxps balanced` -- `set radio.rxps max` -- `set radio.rxps max preamble <16|32>` -- `set radio.rxps overdrive` -- `set radio.rxps overdrive preamble <16|32>` -- `set radio.rxps riskyWorkingMax` -- `set radio.rxps riskyWorkingMax preamble <16|32>` -- `set radio.rxps <1-10>` -- `set radio.rxps level <1-10>` -- `set radio.rxps level <1-10> preamble <16|32>` -- `set radio.rxps ` - -**Parameters:** -- `rx_us`, `sleep_us`: Receive and sleep durations in microseconds (`1000`-`30000000`). -- `level`: `1`-`8`, expressed as **symbols of the sender's preamble the receiver catches** - how much of it lands inside an open RX window. Level `1` catches the most (15 symbols on the 16-symbol profile, 24 on the 32-symbol one) and costs the most power; level `8` catches 8 on both profiles and costs the least. Eight is the floor because that is what an LR11x0 needs to latch, so a level means the same geometry on either radio family. `9` (`overdrive`) and `10` (`riskyWorkingMax`) go below that floor and outside the datasheet timer condition; see below. -- `preamble`: LoRa preamble length in symbols; `16` or `32`. -- `state`: `on` or `off`. - -**Notes:** -- `on` and `conservative` select level `3` (catches 13 symbols), `balanced` selects level `6` (catches 10), and `max` selects level `8` (catches 8); all three use a 16-symbol preamble. -- Levels `1`-`8` satisfy the SX1261/2 duty-cycle timer condition; `max` is the top of that range. `overdrive` (level `9`) and `riskyWorkingMax` (level `10`) trade that guarantee for lower measured duty cycles; see the fuller entries below. -- Like the other named profiles, `max`, `overdrive`, and `riskyWorkingMax` assume a 16-symbol sender preamble. At SF5-SF8 that is markedly less economical than the equivalent numeric level, which follows the SF onto the 32-symbol profile. Use the explicit `preamble 32` form to select the 32-symbol profile. -- Level-based settings automatically recalculate their timings when the spreading factor or bandwidth changes. Custom ` ` timings remain fixed. -- The selected mode is applied immediately, persisted, and restored after reboot. - ---- - #### View or change the LoRa FEM receive-path gain state on supported boards **Usage:** - `get radio.fem.rxgain` @@ -318,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`. --- @@ -340,135 +310,6 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- -#### View or change RX power saving -**Usage:** -- `get radio.rxps` -- `set radio.rxps off` -- `set radio.rxps on` -- `set radio.rxps conservative` -- `set radio.rxps balanced` -- `set radio.rxps max` -- `set radio.rxps max preamble ` -- `set radio.rxps overdrive` -- `set radio.rxps overdrive preamble ` -- `set radio.rxps riskyWorkingMax` -- `set radio.rxps riskyWorkingMax preamble ` -- `set radio.rxps ` -- `set radio.rxps level ` -- `set radio.rxps level preamble ` -- `set radio.rxps ` - -**Parameters:** -- `level`: `1-10`. Levels `1`-`8` are the guarded scale and are expressed in **symbols of the sender's preamble caught**: 15, 14, 13, 12, 11, 10, 9, 8 on the 16-symbol profile and 24, 20, 16, 14, 12, 10, 9, 8 on the 32-symbol one. Level `8` is named `max`. `9` (`overdrive`) and `10` (`riskyWorkingMax`) drop below the 8-symbol floor and outside the datasheet timer condition; only `10` is expected to lose packets. -- `symbols`: `16` or `32` preamble symbols. -- `rx_us`: receive-window duration in microseconds, `1000-30000000`. -- `sleep_us`: radio sleep duration in microseconds, `1000-30000000`. - -**Repeater default:** `off` - -**Profiles:** -- `on` and `conservative`: level 3, catches 13 symbols, with a 16-symbol preamble. -- `balanced`: level 6, catches 10 symbols, with a 16-symbol preamble. -- `max`: level 8, catches the 8-symbol floor, the cheapest guarded setting that still works on both radio families, with a 16-symbol preamble. -- `overdrive`: level 9, with a 16-symbol preamble. Outside the datasheet timer condition; see below. -- `riskyWorkingMax`: level 10, with a 16-symbol preamble. The measured edge of what still works, and the only setting here that is expected to drop packets; see below. -- Every named profile assumes a 16-symbol sender, which is the worst case a mixed network can present. At SF5-SF8 this is less economical than a numeric level, because a 32-symbol profile cannot catch a 16-symbol preamble. `max preamble 32`, `overdrive preamble 32`, and `riskyWorkingMax preamble 32` select the 32-symbol profile explicitly. -- A numeric level, or `level `, automatically uses 32 preamble symbols for SF5-SF8 and 16 for SF9-SF12. -- `level preamble ` explicitly fixes the preamble used in the calculation. -- Explicit `rx_us sleep_us` values select manual timing (`level=0`). - -**How the timings are derived:** - -```text -sleep = (preamble - caught symbols) symbols -listen = whatever the duty-cycle timer condition needs at that sleep -``` - -The capture cost - 6 symbols on SX126x, 8 on LR11x0, both measured on the bench - -no longer enters this arithmetic; it is only a validation, since a level that -catches fewer symbols than the radio needs to latch is rejected. That is why the -same level produces the same periods on either family. The listen window is not -a free parameter: -it follows from the timer condition below. Three constraints are applied on top, -all of them measured rather than assumed: - -- **Sleep floor.** The driver subtracts the sleep-to-RX transition (`tcxoDelay + 1000 us`) from the requested sleep before writing the register. Below that the arm call fails outright; just above it the SX1262 arms with no error and then detects no preambles at all. Levels whose own sleep falls under the floor are raised to it, and collapse onto the same timing as a result. If even the floor would break preamble capture - a short symbol leaves no room between the two - the level is rejected with `ERROR: RXPS does not fit this SF/BW with preamble ` rather than silently falling back to continuous RX. -- **Timer condition.** Semtech requires `Tpreamble + Theader <= 2 * rxPeriod + sleepPeriod`, because the radio restarts its receive timer with that value when it detects a preamble. Guarded levels `1`-`8` satisfy it; `overdrive` and `riskyWorkingMax` deliberately do not. When it is broken, an SX1262 usually still receives normally, but isolated `rxPeriod` register values one tick wide lose most of the packets they have already latched, and nothing in the value predicts which. LR11x0 has always enforced an equivalent rule in its driver. -- **Register granularity.** Both radio families program the duty cycle in 15.625 us ticks and truncate on the way in, so the reported periods are snapped to whole ticks and are the ones the hardware actually runs. - -**`overdrive` (level 9):** - -`overdrive` is the geometry that predates the timer condition being enforced: an -8-symbol receive window with the sleep at the capture limit. It breaks the -condition deliberately, in exchange for the lowest duty cycle available. The -name is meant in the overclocking sense - measured to work, outside the vendor's -stated envelope - rather than as a warning that it drops packets. It does not. - -| Assumed sender preamble | `overdrive` | Level 10 | Saving | -|---|---|---|---| -| 32 symbols | 23.5% | 27.5% (SF8) - 33.4% (SF6) | 4.0 - 9.9 pp | -| 16 symbols | 44.4% | 49.7% (SF8) - 56.6% (SF6) | 5.3 - 12.2 pp | - -Both figures are dimensionless in symbols, so they hold at any spreading factor. -The sleep floor and tick rounding still apply, and the receive window is moved -one tick if it lands on a register value measured to misbehave. That list of -values is not exhaustive, which is the whole of the residual risk: levels -`1`-`8` are safe on any register value, `overdrive` is safe on every value that -has been measured. It is a text-CLI setting only and is never selected by -companion firmware. - -**`riskyWorkingMax` (level 10):** - -The end of the road: the profile extrapolated past `overdrive` until packet -delivery started to fall, then stepped back to the last setting that still held. The -sleep goes past the capture budget on purpose, so a preamble sometimes arrives -while the radio is asleep - which is exactly why delivery falls. Measured at SF8 -with an LR1110 receiver witnessing every transmission on the same link: - -| Assumed sender preamble | Delivery | Sleep share | vs `overdrive` | -|---|---|---|---| -| 32 symbols | 196/200 (98.0%) | 79.3% | +2.8 pp sleep, -2.0 pp delivery | -| 16 symbols | 197/200 (98.5%) | 56.4% | +0.9 pp sleep, -1.0 pp delivery | - -`overdrive` on the same link and in the same run read 200/200 and 199/200. So -this level buys a little sleep and pays for it in packets, which is the whole -trade and the reason the name is what it is. The command is case-sensitive and -has no short form. On a mesh that relies on retries the cost may be acceptable; -on a link that matters it is not. - -Level-based settings are recalculated after SF or bandwidth changes. Manual timings are not recalculated. Settings are persisted in `/prefs.json`. Companion firmware does not expose this text command and applies its fixed `balanced` profile (level 6, catches 10 symbols, preamble 16) at startup and after radio-parameter changes. - -`set radio.rxps` echoes the timings it applied, so the periods the radio really -runs are visible immediately: - -```text -OK - on,level=9(overdrive),preamble=16,rx=32782,sleep=40954 -OK - on,level=10,preamble=32,rx=40375,sleep=106500 -OK - on,level=5,preamble=16,rx=49485,sleep=22750 -``` - -`get radio.rxps` reports: - -```text -desired=,effective=,supported=, -level=<0-10>[(overdrive)|(riskyWorkingMax)][,catch=],preamble=<0|16|32>,rx=,sleep=, -err=,fail=[,erx=,eslp=] -``` - -- `desired` is the saved user setting. -- `level=9(overdrive)` and `level=10(riskyWorkingMax)` mark profiles that run outside the datasheet timer condition. Any other level is guarded. -- `catch` is how many symbols of a sender's preamble the node actually catches, and appears only for guarded levels. It is not simply the number the level asks for: the sleep floor can shorten the sleep further, and at SF6 with a 16-symbol profile that collapses levels 1-6 onto the same point. Reporting the requested value there would be a lie, so the effective one is reported instead. -- `effective=armed` means receive duty-cycle is active. -- `effective=continuous` means RXPS is disabled, unsupported, or the last arm attempt fell back to continuous RX. -- `fail` counts failed arm operations; each one falls back to continuous RX. `clear stats` resets both this total and the consecutive-failure backoff, granting three fresh arm attempts. -- `erx` and `eslp` appear only when the driver had to clamp the requested periods, and report the effective periods after driver clamping. On LR1110 the RX window is stretched when `2*rx + sleep` would not cover the extended period Semtech requires, so the real duty cycle can be less economical than `rx`/`sleep` suggest. -- RXPS is currently supported by the SX1262 and LR1110 wrappers. Other radios remain in continuous RX and reject attempts to enable RXPS. -- There is intentionally no RXPS watchdog, watchdog command, or periodic recovery. Recovery is limited to the immediate continuous-RX fallback after an arm error. After 3 consecutive arm failures the node stops retrying on every RX restart and stays in continuous RX until the RXPS configuration is set again or `clear stats` grants a fresh set of attempts. -- On boards with a host-controlled RXEN pin, the RF switch is held in receive mode for the whole duty cycle (otherwise the node would be deaf). An external LNA on that pin therefore stays biased during the sleep windows, so the real power saving is smaller than the `rx`/`sleep` ratio implies. - ---- - - ### System #### View or change this node's name @@ -607,7 +448,7 @@ err=,fail=[,erx=,eslp=] --- -#### View or change this node's power saving flag (Repeater Only) +#### View or change this node's power saving flag (Repeater, room server, sensor and companion) **Usage:** - `powersaving` - `powersaving on` @@ -617,10 +458,12 @@ err=,fail=[,erx=,eslp=] - `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 +**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. + --- #### View or set reboot interval (Repeater and room server) @@ -1000,27 +843,6 @@ adverts running, set an explicit value in the 60-240 range. --- -#### View or set the direct path override for the current remote client -**Usage:** -- `get outpath` -- `set outpath ` -- `set outpath direct` -- `set outpath clear` -- `set outpath flood` - -**Parameters:** -- `hopN_hex`: Hop hash, `2`, `4`, or `6` hex characters. All hops must use the same width. - -**Notes:** -- These commands require remote client context (they target the caller's ACL entry). -- The path hash size is inferred from the hop hash width. -- `outpath` overrides the primary direct route used for replies to the caller. -- `direct` sets a zero-hop direct route for a caller reachable without repeaters. -- `clear` forgets the current direct path and allows normal path discovery to repopulate it. -- `flood` forces replies to use flood packets until the client logs in again. - ---- - #### Create a new region **Usage:** - `region put [parent_name]` @@ -1391,3 +1213,18 @@ 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. --- + +### 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. + +--- From f992ef0c3e02064930ae795071bb7ad5c1e9b7d6 Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sat, 17 Oct 2026 22:41:29 +0700 Subject: [PATCH 17/34] Removed enterDeepSleep for TBeam --- src/helpers/esp32/TBeamBoard.h | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/src/helpers/esp32/TBeamBoard.h b/src/helpers/esp32/TBeamBoard.h index 98bd16bff..543226d66 100644 --- a/src/helpers/esp32/TBeamBoard.h +++ b/src/helpers/esp32/TBeamBoard.h @@ -86,7 +86,6 @@ #include #include "XPowersLib.h" #include "helpers/ESP32Board.h" -#include class TBeamBoard : public ESP32Board { XPowersLibInterface *PMU = NULL; @@ -131,29 +130,6 @@ public: } #endif - void enterDeepSleep(uint32_t secs, int pin_wake_btn) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - // Make sure the DIO1 and NSS GPIOs are hold on required levels during deep sleep - 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); - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet - } else { - esp_sleep_enable_ext1_wakeup( (1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); // wake up on: recv LoRa packet OR wake btn - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - // Finally set ESP32 into sleep - esp_deep_sleep_start(); // CPU halts here and never returns! -} - uint16_t getBattMilliVolts(){ return PMU->getBattVoltage(); } From c1ab0de85fa7baaaf81fa4aa3a9b9383607b1662 Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sat, 17 Oct 2026 23:01:09 +0700 Subject: [PATCH 18/34] Added isReadBusy() for Serial interfaces --- src/helpers/ArduinoSerialInterface.cpp | 5 +++++ src/helpers/ArduinoSerialInterface.h | 3 +++ src/helpers/BaseSerialInterface.h | 3 +++ src/helpers/MultiSerialInterface.h | 17 +++++++++++++++++ src/helpers/esp32/SerialBLEInterface.cpp | 5 +++++ src/helpers/esp32/SerialBLEInterface.h | 3 +++ src/helpers/nrf52/SerialBLEInterface.cpp | 5 +++++ src/helpers/nrf52/SerialBLEInterface.h | 1 + src/helpers/wifi/SerialWifiInterface.cpp | 5 +++++ src/helpers/wifi/SerialWifiInterface.h | 4 ++++ 10 files changed, 51 insertions(+) diff --git a/src/helpers/ArduinoSerialInterface.cpp b/src/helpers/ArduinoSerialInterface.cpp index a01fa5866..afd7525d4 100644 --- a/src/helpers/ArduinoSerialInterface.cpp +++ b/src/helpers/ArduinoSerialInterface.cpp @@ -17,6 +17,11 @@ bool ArduinoSerialInterface::isConnected() const { return true; // no way of knowing, so assume yes } +// PowerSaving variant +bool ArduinoSerialInterface::isReadBusy() const { + return false; +} + bool ArduinoSerialInterface::isWriteBusy() const { return false; } diff --git a/src/helpers/ArduinoSerialInterface.h b/src/helpers/ArduinoSerialInterface.h index c4086353a..78155672d 100644 --- a/src/helpers/ArduinoSerialInterface.h +++ b/src/helpers/ArduinoSerialInterface.h @@ -28,6 +28,9 @@ public: bool isConnected() const override; + // PowerSaving variant + 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/BaseSerialInterface.h b/src/helpers/BaseSerialInterface.h index 23933fcb4..5431bac15 100644 --- a/src/helpers/BaseSerialInterface.h +++ b/src/helpers/BaseSerialInterface.h @@ -16,6 +16,9 @@ public: virtual bool isConnected() const = 0; virtual void loop() {}; + // PowerSaving variant + virtual bool isReadBusy() const = 0; + 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 f7742b24c..7bb827c18 100644 --- a/src/helpers/MultiSerialInterface.h +++ b/src/helpers/MultiSerialInterface.h @@ -140,6 +140,23 @@ public: } } + 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 6371cf335..176fd988f 100644 --- a/src/helpers/esp32/SerialBLEInterface.cpp +++ b/src/helpers/esp32/SerialBLEInterface.cpp @@ -188,6 +188,11 @@ size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) { #define BLE_WRITE_MIN_INTERVAL 60 +// PowerSaving variant +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 9fb6adba9..063d382c6 100644 --- a/src/helpers/esp32/SerialBLEInterface.h +++ b/src/helpers/esp32/SerialBLEInterface.h @@ -82,6 +82,9 @@ public: bool isConnected() const override; + // PowerSaving variant + 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 6ed16bc86..46d4a6772 100644 --- a/src/helpers/nrf52/SerialBLEInterface.cpp +++ b/src/helpers/nrf52/SerialBLEInterface.cpp @@ -405,6 +405,11 @@ bool SerialBLEInterface::isConnected() const { return _isDeviceConnected && Bluefruit.connected() > 0; } +// PowerSaving variant +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 de1030548..d3cc50551 100644 --- a/src/helpers/nrf52/SerialBLEInterface.h +++ b/src/helpers/nrf52/SerialBLEInterface.h @@ -66,6 +66,7 @@ public: 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/src/helpers/wifi/SerialWifiInterface.cpp b/src/helpers/wifi/SerialWifiInterface.cpp index 462e3ecc3..61afe77ff 100644 --- a/src/helpers/wifi/SerialWifiInterface.cpp +++ b/src/helpers/wifi/SerialWifiInterface.cpp @@ -39,6 +39,11 @@ size_t SerialWifiInterface::writeFrame(const uint8_t src[], size_t len) { return 0; } +// PowerSaving variant +bool SerialWifiInterface::isReadBusy() const { + return false; +} + bool SerialWifiInterface::isWriteBusy() const { return false; } diff --git a/src/helpers/wifi/SerialWifiInterface.h b/src/helpers/wifi/SerialWifiInterface.h index 19291497f..0ead41574 100644 --- a/src/helpers/wifi/SerialWifiInterface.h +++ b/src/helpers/wifi/SerialWifiInterface.h @@ -52,6 +52,10 @@ public: bool isEnabled() const override { return _isEnabled; } bool isConnected() const override; + + // PowerSaving variant + bool isReadBusy() const override; + bool isWriteBusy() const override; size_t writeFrame(const uint8_t src[], size_t len) override; From 3301a022d707d71288c2cce3bc62f05096a5d524 Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sat, 17 Oct 2026 23:01:47 +0700 Subject: [PATCH 19/34] Fixed dummy methods for ESPNOWRadio --- src/helpers/esp32/ESPNOWRadio.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/helpers/esp32/ESPNOWRadio.h b/src/helpers/esp32/ESPNOWRadio.h index f474215a1..890f4545f 100644 --- a/src/helpers/esp32/ESPNOWRadio.h +++ b/src/helpers/esp32/ESPNOWRadio.h @@ -24,6 +24,9 @@ public: void onSendFinished() override; bool isInRecvMode() const override; + // PowerSaving variant + 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; } @@ -38,7 +41,8 @@ public: * These two functions do nothing for ESP-NOW, but are needed for the * Radio interface. */ - virtual bool setRxBoostedGainMode(bool) { } + // PowerSaving variant + virtual bool setRxBoostedGainMode(bool) { return false; } virtual bool getRxBoostedGainMode() const { return false; } uint32_t intID(); From 552599fb34d425e88bd7d6fef215f7367b875081 Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sat, 17 Oct 2026 23:08:46 +0700 Subject: [PATCH 20/34] Improved sleep() and added enterDeepSleep() --- src/MeshCore.h | 11 +++++++++++ src/helpers/ESP32Board.cpp | 14 +++++++++----- src/helpers/ESP32Board.h | 22 ++++++++++++++++++---- src/helpers/NRF52Board.cpp | 23 +++++++++++++++++++++++ src/helpers/NRF52Board.h | 1 + 5 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/MeshCore.h b/src/MeshCore.h index b9cfb4182..4983c7c63 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 @@ -53,6 +62,7 @@ public: virtual void onAfterTransmit() { } virtual void reboot() = 0; virtual void powerOff() { /* no op */ } + virtual void enterDeepSleep(uint32_t secs) { /* 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. // Default no-op: boards that don't care need not implement anything. @@ -90,6 +100,7 @@ public: // Called from the example main loops. Lets a board feed its watchdog and // run periodic housekeeping. Default no-op. virtual void loop() { /* no op */ } + inline static uint32_t n_cad_busy = 0; }; /** diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index a55abb264..57bff06e1 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -45,11 +45,7 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[]) { } #endif -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(); @@ -74,6 +70,14 @@ void ESP32Board::enterDeepSleep(uint32_t secs) { // Flush serial buffers Serial.flush(); delay(100); +} + +void ESP32Board::powerOff() { + enterDeepSleep(0); // Do not wakeup +} + +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 diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 3f95e6923..a98121c2b 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -21,6 +21,7 @@ class ESP32Board : public mesh::MainBoard { protected: uint8_t startup_reason; bool inhibit_sleep = false; + uint32_t inhibit_sleep_until = 0; static inline portMUX_TYPE sleepMux = portMUX_INITIALIZER_UNLOCKED; public: @@ -66,8 +67,9 @@ public: return raw / 4; } + virtual void shutdownPeripherals(); virtual void powerOff() override; - void enterDeepSleep(uint32_t secs); + virtual void enterDeepSleep(uint32_t secs) override; uint32_t getIRQGpio() override { return P_LORA_DIO_1; // default for SX1262 @@ -75,13 +77,13 @@ public: void sleep(uint32_t secs) override { // Skip if not allow to sleep - if (inhibit_sleep) { + if (inhibit_sleep || (int32_t)(inhibit_sleep_until - millis()) > 0) { delay(1); // Give MCU to OTA to run return; } // Set GPIO wakeup - gpio_num_t wakeupPin = (gpio_num_t)getIRQGpio(); + gpio_num_t wakeupPin = (gpio_num_t)getIRQGpio(); // Configure timer wakeup if (secs > 0) { @@ -100,7 +102,10 @@ public: // Configure GPIO wakeup esp_sleep_enable_gpio_wakeup(); - gpio_wakeup_enable((gpio_num_t)wakeupPin, GPIO_INTR_HIGH_LEVEL); // Wake up when receiving a LoRa packet + gpio_wakeup_enable(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, GPIO_INTR_LOW_LEVEL); // Wakeup when pressing button +#endif // MCU enters light sleep esp_light_sleep_start(); @@ -108,6 +113,15 @@ public: // Avoid ISR flood during wakeup due to HIGH LEVEL interrupt 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); + gpio_set_intr_type((gpio_num_t)PIN_USER_BTN, GPIO_INTR_POSEDGE); + + // Check if waken up by PIN_USER_BTN + if (gpio_get_level((gpio_num_t)PIN_USER_BTN) == LOW) { + inhibit_sleep_until = millis() + 30000; // Do not sleep in the next 30s. Should be longer than OLED timeout + } +#endif // Enable CPU interrupt servicing portEXIT_CRITICAL(&sleepMux); diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index f2fa7743a..47563b0ba 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -390,6 +390,29 @@ void NRF52Board::powerOff() { } } +void NRF52Board::enterDeepSleep(uint32_t secs) { + if (secs == 0) { + powerOff(); // Not wake up + } else { + shutdownPeripherals(); + +// Clear FPU interrupt flags to avoid insomnia +// see errata 87 for details +// https://docs.nordicsemi.com/bundle/errata_nRF52840_Rev3/page/ERR/nRF52840/Rev3/latest/anomaly_840_87.html +#if (__FPU_USED == 1) + __set_FPSCR(__get_FPSCR() & ~(0x0000009F)); + (void)__get_FPSCR(); + NVIC_ClearPendingIRQ(FPU_IRQn); +#endif + + // Attemp to sleep + vTaskDelay(pdMS_TO_TICKS(secs * 1000)); + + // Reboot for a fresh recovery + 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 43944b5f7..c361347e1 100644 --- a/src/helpers/NRF52Board.h +++ b/src/helpers/NRF52Board.h @@ -54,6 +54,7 @@ public: virtual void reboot() override { NVIC_SystemReset(); } virtual void shutdownPeripherals(); virtual void powerOff() override; + virtual void enterDeepSleep(uint32_t secs) override; virtual bool getBootloaderVersion(char* version, size_t max_len) override; virtual bool startOTAUpdate(const char *id, char reply[]) override; virtual void sleep(uint32_t secs) override; From 4d9e15d467a942183aad3104fc2219be9b40f5d3 Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sun, 18 Oct 2026 14:01:21 +0700 Subject: [PATCH 21/34] GPS PowerSaving --- .../sensors/EnvironmentSensorManager.cpp | 125 +++++++++++++----- .../sensors/EnvironmentSensorManager.h | 4 + src/helpers/sensors/LocationProvider.h | 51 +++++++ .../sensors/MicroNMEALocationProvider.h | 20 ++- 4 files changed, 167 insertions(+), 33 deletions(-) diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index 0dc892c3a..8f08988d7 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -165,7 +165,11 @@ static RAK12035_SoilMoisture RAK12035; #endif #ifdef RAK_WISBLOCK_GPS -static uint32_t gpsResetPin = 0; +// PowerSaving variant +// -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 @@ -181,6 +185,9 @@ class RAK12500LocationProvider : public LocationProvider { int _sats = 0; long _epoch = 0; bool _fix = false; + + // PowerSaving variant + int _pin_en = -1; public: long getLatitude() override { return _lat; } long getLongitude() override { return _lng; } @@ -193,18 +200,28 @@ public: void begin() override { } void stop() override { } void loop() override { - if (ublox_GNSS.getGnssFixOk(8)) { - _fix = true; - _lat = ublox_GNSS.getLatitude(2) / 10; - _lng = ublox_GNSS.getLongitude(2) / 10; - _alt = ublox_GNSS.getAltitude(2); - _sats = ublox_GNSS.getSIV(2); - } else { - _fix = false; + // PowerSaving variant + static long next_gps_update = 0; + if ((int32_t)(millis() - next_gps_update) >= 0) { + if (ublox_GNSS.getGnssFixOk(8)) { + _fix = true; + _lat = ublox_GNSS.getLatitude(2) / 10; + _lng = ublox_GNSS.getLongitude(2) / 10; + _alt = ublox_GNSS.getAltitude(2); + _sats = ublox_GNSS.getSIV(2); + } else { + _fix = false; + } + _epoch = ublox_GNSS.getUnixEpoch(2); + + next_gps_update = millis() + 1000; } - _epoch = ublox_GNSS.getUnixEpoch(2); } bool isEnabled() override { return true; } + + // PowerSaving variant + void setPinEn(int pin_en) override { _pin_en = pin_en; } + int getPinEn() override { return _pin_en; } }; static RAK12500LocationProvider RAK12500_provider; @@ -731,8 +748,12 @@ bool EnvironmentSensorManager::setSettingValue(const char* name, const char* val #if ENV_INCLUDE_GPS if (gps_detected && strcmp(name, "gps") == 0) { if (strcmp(value, "0") == 0) { + // PowerSaving variant + gps_active = false; // Disabled by CLI or App stop_gps(); } else { + // PowerSaving variant + gps_active = true; // Enabled by CLI or App start_gps(); } return true; @@ -805,14 +826,10 @@ void EnvironmentSensorManager::rakGPSInit(){ Serial1.begin(9600); #endif - //search for the correct IO standby pin depending on socket used - if(gpsIsAwake(WB_IO2)){ - } - else if(gpsIsAwake(WB_IO4)){ - } - else if(gpsIsAwake(WB_IO5)){ - } - else{ + // search for the correct IO standby pin depending on socket used + if (gpsIsAwake(WB_IO2)) { + _location->setPinEn(WB_IO2); // WB_IO2 is the power switch for all sensor and IO slots + } else { MESH_DEBUG_PRINTLN("No GPS found"); gps_active = false; gps_detected = false; @@ -866,13 +883,9 @@ bool EnvironmentSensorManager::gpsIsAwake(uint8_t ioPin){ _location = &RAK12500_provider; return true; - } else if (Serial1.available()) { + } else if (Serial1.available()) { // RAK12501 (L76K) on UART MESH_DEBUG_PRINTLN("Serial GPS init correctly and is turned on"); -#ifdef PIN_GPS_EN - if(PIN_GPS_EN){ - gpsResetPin = PIN_GPS_EN; - } -#endif + gpsResetPin = ioPin; // WB_IO2 serialGPSFlag = true; gps_active = true; gps_detected = true; @@ -888,7 +901,16 @@ bool EnvironmentSensorManager::gpsIsAwake(uint8_t ioPin){ #endif void EnvironmentSensorManager::start_gps() { - gps_active = true; + // PowerSaving variant + if (_location->isPowerSavingEnabled()) { + gps_wake = true; // gps_active is unchanged (true for GPS sleep, false for GPS off) + _location->syncTime(); // Clear GPS data and force sync time + _location->setNextSleep(); // Next time to off + } else { + gps_active = true; + gps_wake = true; + } + #ifdef RAK_WISBLOCK_GPS #ifndef RAK_3401 pinMode(gpsResetPin, OUTPUT); @@ -906,7 +928,16 @@ void EnvironmentSensorManager::start_gps() { } void EnvironmentSensorManager::stop_gps() { - gps_active = false; + // PowerSaving variant + if (_location->isPowerSavingEnabled()) { + gps_wake = false; // gps_active is unchanged (true for GPS sleep, false for GPS off) + _location->stopTimeSync(); // Stop time sync + _location->setNextWake(); // Next time to on + } else { + gps_active = false; + gps_wake = false; + } + #ifdef RAK_WISBLOCK_GPS #ifndef RAK_3401 // rak3401 shouldn't turn off WB_IO2 as it powers the PA pinMode(gpsResetPin, OUTPUT); @@ -927,13 +958,41 @@ void EnvironmentSensorManager::stop_gps() { void EnvironmentSensorManager::loop() { #if ENV_INCLUDE_GPS - static unsigned long next_gps_update = 0; - if (gps_active) { + static long next_gps_update = 0; + + // PowerSaving variant + if (_location->isPowerSavingEnabled() && gps_detected && gps_active) { + // Handle change in PowerSaving mode + _location->updatePowerSavingSettings(gps_wake); + + if (gps_wake) { + // GPS is awake: check whether it should sleep + if ((int32_t)(millis() - _location->getNextSleep()) >= 0) { + POWERSAVING_DEBUG_PRINTLN("GPS wake timeout. Enter sleep"); + stop_gps(); + } else if (!_location->waitingTimeSync()) { + POWERSAVING_DEBUG_PRINTLN("GPS set. Enter sleep early"); + stop_gps(); + } + } else { + // GPS is asleep: check whether it should wake + if ((int32_t)(millis() - _location->getNextWake()) >= 0) { + POWERSAVING_DEBUG_PRINTLN("GPS sleep timeout. Wakeup."); + start_gps(); + } else if (_location->waitingTimeSync()) { + POWERSAVING_DEBUG_PRINTLN("CLI gps sync. Wakeup"); + start_gps(); + } + } + } + + if ((!_location->isPowerSavingEnabled() && gps_active) || (_location->isPowerSavingEnabled() && gps_wake)) { _location->loop(); } - if ((long)(millis() - next_gps_update) > 0) { - if(gps_active){ + // PowerSaving variant + if ((int32_t)(millis() - next_gps_update) >= 0) { + if((!_location->isPowerSavingEnabled() && gps_active) || (_location->isPowerSavingEnabled() && gps_wake)){ #ifdef RAK_WISBLOCK_GPS if ((i2cGPSFlag || serialGPSFlag) && _location->isValid()) { node_lat = ((double)_location->getLatitude())/1000000.; @@ -951,8 +1010,12 @@ void EnvironmentSensorManager::loop() { MESH_DEBUG_PRINTLN("lat %f lon %f alt %f", node_lat, node_lon, node_altitude); } #endif + + // In powersaving mode, GPS is on and off. Only update data when GPS is on + if(_location->isPowerSavingEnabled()) next_gps_update = millis() + (gps_update_interval_sec * 1000); } - next_gps_update = millis() + (gps_update_interval_sec * 1000); + + if(!_location->isPowerSavingEnabled()) next_gps_update = millis() + (gps_update_interval_sec * 1000); } #endif #if ENV_INCLUDE_BME680_BSEC diff --git a/src/helpers/sensors/EnvironmentSensorManager.h b/src/helpers/sensors/EnvironmentSensorManager.h index 17706cbc4..8ef8d417c 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.h +++ b/src/helpers/sensors/EnvironmentSensorManager.h @@ -21,6 +21,10 @@ protected: bool gps_detected = false; bool gps_active = false; + + // PowerSaving variant + bool gps_wake = false; // In PowerSaving, gps_active can have gps_wake true or false + uint32_t gps_update_interval_sec = 1; #if ENV_INCLUDE_GPS diff --git a/src/helpers/sensors/LocationProvider.h b/src/helpers/sensors/LocationProvider.h index bc4f1fe76..61f3acaaf 100644 --- a/src/helpers/sensors/LocationProvider.h +++ b/src/helpers/sensors/LocationProvider.h @@ -9,9 +9,55 @@ class LocationProvider { protected: bool _time_sync_needed = true; + // PowerSaving variant + uint8_t* powersaving_enabled = nullptr; + bool _powersaving_enabled_prev = false; // Track previous PowerSaving state + unsigned long _wake_duration_secs = 86400; // Full day + unsigned long _sleep_duration_secs = 0; // No off + unsigned long _next_wake = 0; + unsigned long _next_sleep = 0; + unsigned long _last_valid_time_sync = 0; + public: virtual void syncTime() { _time_sync_needed = true; } virtual bool waitingTimeSync() { return _time_sync_needed; } + + // PowerSaving variant + virtual void stopTimeSync() { _time_sync_needed = false; } + + virtual void setPowerSavingProfile(uint8_t &enabled, unsigned long wake_duration_secs, + unsigned long sleep_duration_secs) { + powersaving_enabled = &enabled; + _wake_duration_secs = wake_duration_secs; + _sleep_duration_secs = sleep_duration_secs; + } + + virtual bool isPowerSavingEnabled() const { + return powersaving_enabled != nullptr && *powersaving_enabled != 0; + } + + virtual void updatePowerSavingSettings(bool gps_wake) { + bool powersaving_enabled = isPowerSavingEnabled(); + + // PowerSaving changed from OFF to ON + if (!_powersaving_enabled_prev && powersaving_enabled) { + if (gps_wake) { + syncTime(); // Wake when powersaving on + setNextSleep(); // On, set next sleep + } else { + setNextWake(); // Off, set next wake + } + } + + _powersaving_enabled_prev = powersaving_enabled; + } + + virtual void setNextWake() { _next_wake = millis() + _sleep_duration_secs * 1000UL; } + virtual unsigned long getNextWake() { return _next_wake; } + virtual void setNextSleep() { _next_sleep = millis() + _wake_duration_secs * 1000UL; } + virtual unsigned long getNextSleep() { return _next_sleep; } + virtual unsigned long getLastValidTimeSync() { return _last_valid_time_sync; } + virtual long getLatitude() = 0; virtual long getLongitude() = 0; virtual long getAltitude() = 0; @@ -27,6 +73,11 @@ public: virtual void loop() = 0; virtual bool isEnabled() = 0; + // PowerSaving variant + virtual void setPinEn(int pin_en) = 0; + virtual int getPinEn() = 0; + + // Format compact diagnostics in the caller-provided buffer. Providers that // have more information (for example UART counters) can override this. virtual void formatDiagnostics(char* out, size_t out_size) { diff --git a/src/helpers/sensors/MicroNMEALocationProvider.h b/src/helpers/sensors/MicroNMEALocationProvider.h index 37ef5762c..1899fa242 100644 --- a/src/helpers/sensors/MicroNMEALocationProvider.h +++ b/src/helpers/sensors/MicroNMEALocationProvider.h @@ -157,6 +157,15 @@ public : } } + // PowerSaving variant + void setPinEn(int pin_en) override { + _pin_en = pin_en; + } + + int getPinEn() override { + return _pin_en; + } + void syncTime() override { nmea.clear(); LocationProvider::syncTime(); } long getLatitude() override { return nmea.getLatitude(); } long getLongitude() override { return nmea.getLongitude(); } @@ -166,7 +175,9 @@ public : return alt; } long satellitesCount() override { return nmea.getNumSatellites(); } - bool isValid() override { return nmea.isValid(); } + + // PowerSaving variant + bool isValid() override { return nmea.isValid() && nmea.getYear() < 2030; } // TODO: Pending better validation long getTimestamp() override { DateTime dt(nmea.getYear(), nmea.getMonth(),nmea.getDay(),nmea.getHour(),nmea.getMinute(),nmea.getSecond()); @@ -247,9 +258,14 @@ public : _clock->setCurrentTime(getTimestamp()); _time_sync_needed = false; _last_time_sync = millis(); + + // PowerSaving variant + _last_valid_time_sync = _clock->getCurrentTime(); } } - if (isValid()) { + + // PowerSaving variant + if (isValid() && satellitesCount() >= 5) { time_valid ++; } } From c44c9c6ac83305e346ae5b4a5ec97665934510c4 Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sun, 18 Oct 2026 14:25:56 +0700 Subject: [PATCH 22/34] CommonCLI for PowerSaving --- src/MeshCore.h | 1 - src/helpers/CommonCLI.cpp | 119 ++++++++++++++++++++++++++++++++++---- src/helpers/CommonCLI.h | 17 ++++++ 3 files changed, 125 insertions(+), 12 deletions(-) diff --git a/src/MeshCore.h b/src/MeshCore.h index 4983c7c63..8e50b3a4f 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -100,7 +100,6 @@ public: // Called from the example main loops. Lets a board feed its watchdog and // run periodic housekeeping. Default no-op. virtual void loop() { /* no op */ } - inline static uint32_t n_cad_busy = 0; }; /** diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 04199e24d..9de3c2a47 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -4,6 +4,8 @@ #include "AdvertDataHelpers.h" #include "TxtDataHelpers.h" #include +#define STR_HELPER(x) #x +#define STR(x) STR_HELPER(x) #ifndef BRIDGE_MAX_BAUD #define BRIDGE_MAX_BAUD 115200 @@ -90,7 +92,9 @@ 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 + // PowerSaving variant + 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 @@ -127,6 +131,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Legacy _prefs->powersaving_enabled = constrain(_prefs->powersaving_enabled, 0, 1); + // PowerSaving variant + _prefs->reboot_interval = constrain(_prefs->reboot_interval, 0, 255); _prefs->gps_enabled = constrain(_prefs->gps_enabled, 0, 1); _prefs->advert_loc_policy = constrain(_prefs->advert_loc_policy, 0, 2); @@ -348,7 +354,13 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re if (_sensors->setSettingValue("gps", "1")) { _prefs->gps_enabled = 1; savePrefs(); - strcpy(reply, "ok"); + + // PowerSaving variant + if (_prefs->powersaving_enabled) { + strcpy(reply, "on (powersaving)"); + } else { // Normal mode + strcpy(reply, "ok"); + } } else { strcpy(reply, "gps toggle not found"); } @@ -423,14 +435,46 @@ 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"); - if (enabled) { - sprintf(reply, "on, %s, %s, %d sats", - active?"active":"deactivated", - fix?"fix":"no fix", - sats); - } else { - strcpy(reply, "off"); + // PowerSaving variant + bool active = !strcmp(_sensors->getSettingByKey("gps") ?: "0", "1"); // Use "0" if GPS setting is not found + + // PowerSaving variant + if (l->isPowerSavingEnabled()) { // GPS Power Saving + if(active) { + if (enabled) { + unsigned long mins = (l->getNextSleep() - millis()) / 60000UL; + sprintf(reply, "on, powered (powersaving, sleep in %luh %lum), %s, %d sats", + mins / 60UL, + mins % 60UL, + fix ? "fix" : "no fix", + sats); + } else { + unsigned long mins = (l->getNextWake() - millis()) / 60000UL; + sprintf(reply, "on, unpowered (powersaving, wake in %luh %lum)", + mins / 60UL, + mins % 60UL); + } + + // "last sync" from GPS + DateTime dt = DateTime(l->getLastValidTimeSync()); + if (dt.unixtime() == 0) { + sprintf(reply + strlen(reply), ", last sync: none"); + } else { + sprintf(reply + strlen(reply), ", last sync: %02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), + dt.day(), dt.month(), dt.year()); + } + } else { + sprintf(reply, "off, %s", enabled ? "powered" : "unpowered"); + } + } else { // Normal mode + if(active) { + sprintf(reply, "on, %s, %s, %d sats", + enabled?"powered":"unpowered", + fix?"fix":"no fix", + sats); + } else { + sprintf(reply, "off, %s", enabled ? "powered" : "unpowered"); + } } } else { strcpy(reply, "Can't find GPS"); @@ -444,7 +488,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re #elif defined(ESP32) && !defined(WITH_BRIDGE) _prefs->powersaving_enabled = 1; savePrefs(); - strcpy(reply, "on - After 2 minutes"); + // PowerSaving variant + strcpy(reply, "on - After 2 minutes. Serial console will be off."); #elif defined(WITH_BRIDGE) strcpy(reply, "Bridge not supported"); #else @@ -460,6 +505,37 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } else { strcpy(reply, "off"); } + // PowerSaving variant + } else if (memcmp(command, "sensor", 6) == 0) { + // I2C +#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL) + sprintf(reply, "I2C Wire1: SDA=%s,SCL=%s\r\n", STR(ENV_PIN_SDA), STR(ENV_PIN_SCL)); +#elif defined(PIN_BOARD_SDA) && defined(PIN_BOARD_SCL) + sprintf(reply, "I2C Wire: SDA=%s, SCL=%s\r\n", STR(PIN_BOARD_SDA), STR(PIN_BOARD_SCL)); +#elif defined(PIN_WIRE_SDA) && defined(PIN_WIRE_SCL) + sprintf(reply, "I2C Wire: SDA=%s, SCL=%s\r\n", STR(PIN_WIRE_SDA), STR(PIN_WIRE_SCL)); +#else + sprintf(reply, "I2C GPIOs not defined\r\n"); +#endif + + // GPS +#if defined(PIN_GPS_RX) && defined(PIN_GPS_TX) + sprintf(reply + strlen(reply), "GPS Serial: RX=%s, TX=%s", STR(PIN_GPS_RX), STR(PIN_GPS_TX)); +#ifdef ENV_INCLUDE_GPS> 0 + sprintf(reply + strlen(reply), ". Configured"); +#else + sprintf(reply + strlen(reply), ". Not configured"); +#endif +#else + sprintf(reply + strlen(reply), "GPS Serial not defined"); +#endif + } else if (memcmp(command, "powerlog", 8) == 0) { + sprintf(reply, "Last reset reason: %s", _board->getResetReasonString(_board->getResetReason())); +#if defined(NRF52_PLATFORM) + sprintf(reply + strlen(reply), "\r\nLast shutdown reason: %s", + _board->getShutdownReasonString(_board->getShutdownReason())); + sprintf(reply + strlen(reply), "\r\nLast boot voltage: %u mV", _board->getBootVoltage()); +#endif } else if (memcmp(command, "log start", 9) == 0) { _callbacks->setLoggingOn(true); strcpy(reply, " logging on"); @@ -692,6 +768,20 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } } #endif + // PowerSaving variant + } else if (memcmp(config, "reboot.interval ", 16) == 0) { + int hours = _atoi(&config[16]); + if (hours == 0) { + _prefs->reboot_interval = 0; + savePrefs(); + strcpy(reply, "reboot.interval disabled"); + } else if (hours < 1 || 255 < hours) { + strcpy(reply, "Error: interval range is 1-255 hours"); + } else { + _prefs->reboot_interval = hours; + savePrefs(); + sprintf(reply, "OK - reboot.interval set to %d", _prefs->reboot_interval); + } } else { strcpy(reply, "unknown config: "); StrHelper::strncpy(&reply[16], config, 160-17); @@ -829,6 +919,13 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep if (tmp == reply) { sprintf(reply, "No extra SF configured"); } + // PowerSaving variant + } else if (memcmp(config, "reboot.interval", 15) == 0) { + if (_prefs->reboot_interval == 0) { + strcpy(reply, "disabled"); + } else { + sprintf(reply, "> %d", (uint8_t)_prefs->reboot_interval); + } } else { sprintf(reply, "??: %s", config); } diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 2759b0c4f..89abb93b0 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -58,6 +58,8 @@ public: char bridge_secret[16]; // for XOR encryption of bridge packets (ESP-NOW only) // Power setting uint8_t powersaving_enabled = 0; // boolean + // PowerSaving variant + uint8_t reboot_interval = 0; // hours, 0-255 (default 0=disable) // Gps settings uint8_t gps_enabled = 0; uint32_t gps_interval = 0; // in seconds @@ -66,6 +68,8 @@ public: float adc_multiplier = 0; char owner_info[120]; uint8_t rx_boosted_gain = 0; // power settings + // PowerSaving variant + 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 @@ -95,9 +99,17 @@ private: def("agc_int", _parent->agc_reset_interval); def("hash_mode", _parent->path_hash_mode); def("multi_ack", _parent->multi_acks); + // PowerSaving variant + 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); } public: RadioPrefs(NodePrefs* parent) : _parent(parent) { } + // PowerSaving variant + RxPowerSavingConfig* getRxPowerSavingConfig() override { return &_parent->rxps; } // CommonRadioPrefs interface float getFreq() const override { return _parent->freq; } void setFreq(float f) override { _parent->freq = f; markDirty(); } @@ -171,6 +183,8 @@ private: void structure() override { def("adc_mult", _parent->adc_multiplier); def("pwr_sav_en", _parent->powersaving_enabled); + // PowerSaving variant + def("pwr_reboot_int", _parent->reboot_interval); } public: PowerPrefs(NodePrefs* parent) : _parent(parent) { } @@ -226,6 +240,8 @@ protected: public: NodePrefs() : ConfigSerializer(), bridge(this), gps(this), radio(this), power(this), repeat(this), room(this), custom(&radio) { + // PowerSaving variant + rxps.enabled = 0; node_name[0] = 0; password[0] = 0; guest_password[0] = 0; @@ -234,6 +250,7 @@ public: } 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(); } From ab2231be9bdae4b87d358915fed0aa2a820fab5b Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sun, 18 Oct 2026 14:27:44 +0700 Subject: [PATCH 23/34] Fixed ST7735Display for PowerSaving --- src/helpers/ui/ST7735Display.cpp | 40 +++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/src/helpers/ui/ST7735Display.cpp b/src/helpers/ui/ST7735Display.cpp index 344f5b1d9..ac0a3f083 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) { @@ -512,6 +512,15 @@ 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_SDA, OUTPUT); + pinMode(PIN_TFT_SCL, OUTPUT); + pinMode(PIN_TFT_RST, OUTPUT); + pinMode(PIN_TFT_LEDA_CTL, OUTPUT); + _resetAndInit(); sendCommand(ST77XX_DISPON); @@ -525,9 +534,18 @@ 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); + + // Prevent back-powering to save 3.5mA (from 12.6 down to 9.1mA) + 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 2195d20b31141e92b8b28ec5d3c2672d447678ea Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sun, 18 Oct 2026 18:18:13 +0700 Subject: [PATCH 24/34] Added RXPS --- src/helpers/CommonRadioPrefs.cpp | 67 +++- src/helpers/radiolib/CustomLR1110.h | 86 +++++ src/helpers/radiolib/CustomLR1110Wrapper.h | 61 +++- src/helpers/radiolib/CustomLR2021.h | 6 + src/helpers/radiolib/CustomSX1262.h | 72 ++++ src/helpers/radiolib/CustomSX1262Wrapper.h | 53 ++- src/helpers/radiolib/RXPowerSaving.cpp | 245 ++++++++++++++ src/helpers/radiolib/RXPowerSaving.h | 275 ++++++++++++++++ src/helpers/radiolib/RXPowerSavingCLI.cpp | 268 +++++++++++++++ src/helpers/radiolib/RXPowerSavingCLI.h | 15 + src/helpers/radiolib/RadioLibWrappers.cpp | 365 ++++++++++++++++++--- src/helpers/radiolib/RadioLibWrappers.h | 91 ++++- 12 files changed, 1548 insertions(+), 56 deletions(-) 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 diff --git a/src/helpers/CommonRadioPrefs.cpp b/src/helpers/CommonRadioPrefs.cpp index 41b179932..8fce22701 100644 --- a/src/helpers/CommonRadioPrefs.cpp +++ b/src/helpers/CommonRadioPrefs.cpp @@ -3,6 +3,34 @@ #include "Utils.h" #include "target.h" +// PowerSaving variant +#include "radiolib/RXPowerSavingCLI.h" + +static RxPowerSavingControl* rxpsControl() { +#ifdef WRAPPER_CLASS + return &radio_driver; +#else + return nullptr; +#endif +} + +// PowerSaving variant +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()); +} + +// PowerSaving variant +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) { snprintf(value, max_len, "%d", (uint32_t)getFEMRxGain()); @@ -29,6 +57,30 @@ bool CommonRadioPrefs::setByKey(const char* key, const char* value) { } bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + // PowerSaving variant + 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 +102,16 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest setCodingRate(cr); setFreq(freq); setBandwidth(bw); - strcpy(reply, "OK - reboot to apply"); + + // PowerSaving variant + 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"); } @@ -110,7 +171,9 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest } if (strcmp(command, "get cad") == 0) { - sprintf(reply, "> %s", isCadEnabled() ? "on" : "off"); + // PowerSaving variant + 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/radiolib/CustomLR1110.h b/src/helpers/radiolib/CustomLR1110.h index f0fde4354..4426d56ac 100644 --- a/src/helpers/radiolib/CustomLR1110.h +++ b/src/helpers/radiolib/CustomLR1110.h @@ -3,6 +3,9 @@ #include #include "MeshCore.h" +// PowerSaving variant +#include "RXPowerSaving.h" + class CustomLR1110 : public LR1110 { uint32_t _preambleMillis = 66; uint32_t _maxPayloadMillis = 3934; @@ -13,16 +16,30 @@ class CustomLR1110 : public LR1110 { public: CustomLR1110(Module *mod) : LR1110(mod) { } + // PowerSaving variant + // 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); + // PowerSaving variant + 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; } + // PowerSaving variant + 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 +55,69 @@ class CustomLR1110 : public LR1110 { float getFreqMHz() const { return freqMHz; } + // PowerSaving variant + // 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 +130,12 @@ 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); } + // PowerSaving variant + 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 e7aaeb937..5d82e7b1d 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -6,9 +6,28 @@ class CustomLR1110Wrapper : public RadioLibWrapper { public: + // PowerSaving variant + // 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; + } + + // PowerSaving variant + // 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 { + // PowerSaving variant + prepareForRadioConfig(); + ((CustomLR1110 *)_radio)->setFrequency(freq); ((CustomLR1110 *)_radio)->setSpreadingFactor(sf); ((CustomLR1110 *)_radio)->setBandwidth(bw); @@ -20,8 +39,18 @@ public: } bool isReceivingPacket() override { + // PowerSaving variant + // 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(); } + // PowerSaving variant + bool isChipBusy() override { + return ((CustomLR1110 *)_radio)->isChipBusy(); + } float getCurrentRSSI() override { float rssi = -110; ((CustomLR1110 *)_radio)->getRssiInst(&rssi); @@ -38,12 +67,40 @@ public: _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: + // PowerSaving variant + 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; + } + + // PowerSaving variant + 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); + } + + // PowerSaving variant + 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 { + // PowerSaving variant + prepareForRadioConfig(); + return ((CustomLR1110 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } bool getRxBoostedGainMode() const override { diff --git a/src/helpers/radiolib/CustomLR2021.h b/src/helpers/radiolib/CustomLR2021.h index 0255b93cf..7e5aa2e9e 100644 --- a/src/helpers/radiolib/CustomLR2021.h +++ b/src/helpers/radiolib/CustomLR2021.h @@ -68,6 +68,12 @@ class CustomLR2021 : public LR2021 { float getFreqMHz() const { return freqMHz; } + // PowerSaving variant + 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 { diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index b4ee6c97a..46bb7fc87 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -3,6 +3,9 @@ #include #include "MeshCore.h" +// PowerSaving variant +#include "RXPowerSaving.h" // MC_TCXO_DELAY_US + class CustomSX1262 : public SX1262 { uint32_t _preambleMillis = 66; uint32_t _maxPayloadMillis = 3934; @@ -10,8 +13,43 @@ class CustomSX1262 : public SX1262 { bool _headerSeen = false; public: + // PowerSaving variant + // 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) { } + // PowerSaving variant + // 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; + } + + // PowerSaving variant + // 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 +143,26 @@ 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); } + // PowerSaving variant + 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; + } + + // PowerSaving variant + 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 +206,20 @@ class CustomSX1262 : public SX1262 { return false; } + // PowerSaving variant + 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); diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index be3144716..7a6d8b855 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -10,9 +10,21 @@ class CustomSX1262Wrapper : public RadioLibWrapper { public: + // PowerSaving variant + // 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 { + // PowerSaving variant + prepareForRadioConfig(); ((CustomSX1262 *)_radio)->setFrequency(freq); ((CustomSX1262 *)_radio)->setSpreadingFactor(sf); ((CustomSX1262 *)_radio)->setBandwidth(bw); @@ -23,14 +35,22 @@ public: ((CustomSX1262 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); } - bool isReceivingPacket() override { + bool isReceivingPacket() override { + // PowerSaving variant + // 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(); } + // PowerSaving variant + 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 +58,37 @@ public: } uint8_t getSpreadingFactor() const override { return ((CustomSX1262 *)_radio)->spreadingFactor; } virtual void powerOff() override { + // PowerSaving variant + if (_rx_ps_armed) stopReceiveDutyCycle(); ((CustomSX1262 *)_radio)->sleep(false); } + // PowerSaving variant + bool supportsRxPowerSaving() const override { return true; } + +protected: + // PowerSaving variant + 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); + } + + // PowerSaving variant + 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 { + // PowerSaving variant + prepareForRadioConfig(); return ((CustomSX1262 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } bool getRxBoostedGainMode() const override { diff --git a/src/helpers/radiolib/RXPowerSaving.cpp b/src/helpers/radiolib/RXPowerSaving.cpp new file mode 100644 index 000000000..6a357390d --- /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 000000000..5f52560e4 --- /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 000000000..d9b9b7511 --- /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 000000000..62acfb95a --- /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/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index e4d2ba1c2..59c8def0d 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -11,6 +11,13 @@ #define NUM_NOISE_FLOOR_SAMPLES 64 #define SAMPLING_THRESHOLD 14 +// PowerSaving variant +#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 +55,39 @@ uint32_t RadioLibWrapper::getRngSeed() { } void RadioLibWrapper::setTxPower(int8_t dbm) { + // PowerSaving variant + prepareForRadioConfig(); + #if defined(USE_LR2021) + // PowerSaving variant + // 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() { + // PowerSaving variant + if (_rx_ps_armed) stopReceiveDutyCycle(); + _radio->standby(); state = STATE_IDLE; // need another startReceive() } +// PowerSaving variant +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 @@ -72,40 +101,150 @@ void RadioLibWrapper::doResetAGC() { } void RadioLibWrapper::resetAGC() { - // make sure we're not mid-receive of packet! - if ((state & STATE_INT_READY) != 0 || isReceivingPacket()) return; + // PowerSaving variant + // make sure we're not mid-receive or mid-transmit of a packet + if (isPacketPendingOrReceiving() || state == STATE_TX_WAIT) return; + + // PowerSaving variant + 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. + // PowerSaving variant + // 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; -} -void RadioLibWrapper::loop() { - if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { - if (!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; + // PowerSaving variant + _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); } } - } 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 - } - _floor_sample_sum = 0; + } - #ifdef MESH_DEBUG_NOISE_FLOOR - MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor); - #endif + 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(); +} + +// PowerSaving variant +// 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(); +} + +// PowerSaving variant +bool RadioLibWrapper::isPacketPendingOrReceiving() { + return (state & STATE_INT_READY) != 0 || isReceivingPacket(); +} + +// PowerSaving variant +void RadioLibWrapper::sampleNoiseFloorOnce() { + int rssi = getCurrentRSSI(); + if (rssi < _noise_floor + SAMPLING_THRESHOLD) { + _num_floor_samples++; + _floor_sample_sum += rssi; + } +} + +// PowerSaving variant +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; +} + +// PowerSaving variant +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(); + } +} + +// PowerSaving variant +void RadioLibWrapper::endNoiseFloorCalib(unsigned long now) { + _nf_calib_active = false; + _nf_last_calib = now; + if (!isPacketPendingOrReceiving()) requestRestartRecv(); +} + +// PowerSaving variant +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()); } } @@ -113,7 +252,9 @@ 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(); + + // PowerSaving variant + int err = startReceiveMode(); if (err == RADIOLIB_ERR_NONE) { state = STATE_RX; } else { @@ -121,6 +262,123 @@ void RadioLibWrapper::startRecv() { } } +// PowerSaving variant +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; + 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 = + 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_arm_retry.recordSuccess(); + _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++; + _rx_ps_arm_retry.recordFailure(); + MESH_DEBUG_PRINTLN("RadioLibWrapper: startReceiveDutyCycle(%d), continuous RX fallback", err); + stopDutyCycleHardware(); + int16_t fallback_err = startContinuousReceive(); + if (fallback_err != RADIOLIB_ERR_NONE) _rx_ps_last_error = fallback_err; + return fallback_err; +} + +// PowerSaving variant +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; +} + +// PowerSaving variant +bool RadioLibWrapper::isPacketReady() { + if (!_rx_ps_armed) return true; + return _radio->checkIrq(RADIOLIB_IRQ_RX_DONE) != 0; +} + +// PowerSaving variant +void RadioLibWrapper::prepareForRadioConfig() { + if (!_rx_ps_armed) return; + + stopReceiveDutyCycle(); + requestRestartRecv(); +} + +// PowerSaving variant +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 + _rx_ps_restart_pending = true; + if ((state & ~STATE_INT_READY) != STATE_TX_WAIT && !isPacketPendingOrReceiving()) { + _rx_ps_restart_pending = false; + requestRestartRecv(); + } + return true; +} + +// PowerSaving variant +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 +386,25 @@ 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++; + // PowerSaving variant + if (isPacketReady()) { + if (_rx_ps_armed) stopReceiveDutyCycle(); + len = _radio->getPacketLength(); + if (len > 0) { + if (len > sz) { len = sz; } + // PowerSaving variant + _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 +415,8 @@ 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); - } + // PowerSaving variant + startRecv(); } return len; } @@ -164,6 +426,9 @@ uint32_t RadioLibWrapper::getEstAirtimeFor(int len_bytes) { } bool RadioLibWrapper::startSendRaw(const uint8_t* bytes, int len) { + // PowerSaving variant + if (_rx_ps_armed) stopReceiveDutyCycle(); + _board->onBeforeTransmit(); int err = _radio->startTransmit((uint8_t *) bytes, len); if (err == RADIOLIB_ERR_NONE) { @@ -196,17 +461,23 @@ int16_t RadioLibWrapper::performChannelScan() { } bool RadioLibWrapper::isChannelActive() { + // PowerSaving variant // 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) { + // PowerSaving variant + 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 // try to read a non-existent packet and count a spurious recv error. state = STATE_IDLE; startRecv(); + // PowerSaving variant + if (result == RADIOLIB_LORA_DETECTED) ++_cad_busy_count; if (result != RADIOLIB_CHANNEL_FREE) return true; } @@ -214,10 +485,14 @@ bool RadioLibWrapper::isChannelActive() { } float RadioLibWrapper::getLastRSSI() const { - return _radio->getRSSI(); + // PowerSaving variant + if (_last_metrics_valid) return _last_rssi; + return _rx_ps_armed ? 0 : _radio->getRSSI(); } float RadioLibWrapper::getLastSNR() const { - return _radio->getSNR(); + // PowerSaving variant + 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 77dd93116..509d9dd07 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -2,6 +2,8 @@ #include #include +// PowerSaving variant +#include "RXPowerSaving.h" #ifdef USE_CC310_HW_CRYPTO #include @@ -11,28 +13,89 @@ struct PacketMillis { uint32_t payloadMillis; // header-valid -> rx-done deadline }; -class RadioLibWrapper : public mesh::Radio { +// PowerSaving variant +class RadioLibWrapper : public mesh::Radio, public RxPowerSavingControl { protected: PhysicalLayer* _radio; mesh::MainBoard* _board; uint32_t n_recv, n_sent, n_recv_errors; + // PowerSaving variant + uint32_t _cad_busy_count = 0; int16_t _noise_floor, _threshold; + // PowerSaving variant + 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; + // PowerSaving variant + bool _rx_ps_restart_pending = false; + 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(); + + // PowerSaving variant + 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 int16_t startContinuousReceive() { return _radio->startReceive(); } + 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; } + // PowerSaving variant + 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; @@ -41,6 +104,15 @@ public: bool isInRecvMode() const override; bool isChannelActive(); + // PowerSaving variant + 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 hasPendingReceive() { return isPacketPendingOrReceiving(); } + bool isReceiving() override { if (isReceivingPacket()) return true; @@ -65,10 +137,21 @@ public: void loop() override; + // PowerSaving variant + 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 = 0; } + + // PowerSaving variant + 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(); + } virtual float getLastRSSI() const override; virtual float getLastSNR() const override; From 7213cb81665655683b785976ca7db4d626f4852f Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sun, 18 Oct 2026 18:26:03 +0700 Subject: [PATCH 25/34] RXPS in CommonRadioPrefs --- src/helpers/CommonRadioPrefs.h | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/helpers/CommonRadioPrefs.h b/src/helpers/CommonRadioPrefs.h index 0e83ca9df..3be046528 100644 --- a/src/helpers/CommonRadioPrefs.h +++ b/src/helpers/CommonRadioPrefs.h @@ -1,9 +1,14 @@ #pragma once #include "ConfigSerializer.h" #include "KeyValueStore.h" +#include "radiolib/RXPowerSaving.h" class CommonRadioPrefs : public ConfigSerializer, public KeyValueStore { bool _is_dirty = false; + + // PowerSaving variant + uint8_t _active_sf = 0; + float _active_bw = 0; protected: CommonRadioPrefs() { } public: @@ -62,6 +67,11 @@ public: virtual uint8_t getFEMTxGain() const = 0; virtual void setFEMTxGain(uint8_t g) = 0; + // PowerSaving variant + 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 From 60e9e41028c50cc13aebe9668536c1eb658a37ec Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sun, 18 Oct 2026 21:05:46 +0700 Subject: [PATCH 26/34] PowerSaving for repeaters --- examples/simple_repeater/MyMesh.cpp | 32 +++++++++++++++++++++++++++-- examples/simple_repeater/main.cpp | 20 +++++++++++++++++- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 61b6c149e..b76645b78 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -933,13 +933,33 @@ 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; + +// PowerSaving variant +#ifdef RADIO_FEM_RXGAIN + _prefs.radio_fem_rxgain = RADIO_FEM_RXGAIN; +#else + _prefs.radio_fem_rxgain = 0; +#endif + +#ifdef RADIO_FEM_TXGAIN + _prefs.radio_fem_txgain = RADIO_FEM_TXGAIN; // board-specific default for PA +#else _prefs.radio_fem_txgain = 0; +#endif pending_discover_tag = 0; pending_discover_until = 0; memset(default_scope.key, 0, sizeof(default_scope.key)); + + // PowerSaving variant + // PowerSaving defaults + _prefs.powersaving_enabled = 1; + + // RXPS defaults - Balanced profile + _prefs.rxps.enabled = 1; + _prefs.rxps.level = 6; + _prefs.rxps.preamble = 16; } void MyMesh::begin(FILESYSTEM *fs) { @@ -978,6 +998,8 @@ void MyMesh::begin(FILESYSTEM *fs) { #endif radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + // PowerSaving variant + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); radio_driver.setTxPower(_prefs.tx_power_dbm); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); @@ -1310,12 +1332,16 @@ 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); + // PowerSaving variant + _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); + // PowerSaving variant + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); MESH_DEBUG_PRINTLN("Radio params restored"); } @@ -1336,5 +1362,7 @@ 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; + // PowerSaving variant + return _mgr->getOutboundTotal() > 0 || _mgr->getInboundTotal() > 0 || // rxdelay packets wait in the inbound queue + isTransmitting() || _prefs.getRadioPrefs()->isRxPowerSavingCalibrationActive(); } diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index d475b13d8..299eb3ed8 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -102,12 +102,24 @@ void setup() { ethernet_command[0] = 0; #endif + // PowerSaving variant +#if ENV_INCLUDE_GPS == 1 + // Apply PowerSaving profile for GPS + if (sensors.getLocationProvider() != NULL) { + // GPS on and off duration in seconds + sensors.getLocationProvider()->setPowerSavingProfile(the_mesh.getNodePrefs()->powersaving_enabled, 600, + 86400); // Max 10 minutes, 1 day + } +#endif + sensors.begin(); the_mesh.begin(fs); #ifdef DISPLAY_CLASS - ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION); + // PowerSaving variant + // Added board for battery display + ui_task.begin(the_mesh.getNodePrefs(), FIRMWARE_BUILD_DATE, FIRMWARE_VERSION, &board); #endif #ifdef ETHERNET_ENABLED @@ -214,4 +226,10 @@ void loop() { } #endif } + + // PowerSaving variant + if (the_mesh.getNodePrefs()->reboot_interval > 0 && + the_mesh.millisHasNowPassed(the_mesh.getNodePrefs()->reboot_interval * 3600000)) { + board.reboot(); + } } From 0b9b6ec5441345a82da4439ba34469f79b6a0a4b Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sun, 18 Oct 2026 21:24:57 +0700 Subject: [PATCH 27/34] PowerSaving for sensors --- examples/simple_sensor/SensorMesh.cpp | 47 ++++++++++++++++++++++++++- examples/simple_sensor/SensorMesh.h | 4 +++ examples/simple_sensor/main.cpp | 37 +++++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index b18606681..00948af26 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -46,6 +46,11 @@ #define SENSOR_READ_INTERVAL_SECS 60 #endif +// PowerSaving variant +#ifndef GPS_READ_INTERVAL_SECS + #define GPS_READ_INTERVAL_SECS 60 +#endif + /* ------------------------------ Code -------------------------------- */ #define FIRMWARE_VER_LEVEL 1 @@ -150,7 +155,14 @@ uint8_t SensorMesh::handleRequest(ClientInfo* from, uint32_t sender_timestamp, u telemetry.reset(); telemetry.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f); + + // PowerSaving variant // query other sensors -- target specific + if (sensors.getLocationProvider() != NULL && + sensors.getLocationProvider()->isPowerSavingEnabled() && + getRTCClock()->getCurrentTime() - sensors.getLocationProvider()->getLastValidTimeSync() >= GPS_READ_INTERVAL_SECS) { + sensors.getLocationProvider()->syncTime(); // Request for GPS sync if GPS is in PowerSaving if later than read interval + } sensors.querySensors(0xFF & perm_mask, telemetry); // allow all telemetry permissions for admin or guest // TODO: let requester know permissions they have: telemetry.addPresence(TELEM_CHANNEL_SELF, perms); float temperature = board.getMCUTemperature(); @@ -820,10 +832,30 @@ 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; + +// PowerSaving variant +#ifdef RADIO_FEM_RXGAIN + _prefs.radio_fem_rxgain = RADIO_FEM_RXGAIN; +#else + _prefs.radio_fem_rxgain = 0; +#endif + +#ifdef RADIO_FEM_TXGAIN + _prefs.radio_fem_txgain = RADIO_FEM_TXGAIN; // board-specific default for PA +#else _prefs.radio_fem_txgain = 0; +#endif memset(default_scope.key, 0, sizeof(default_scope.key)); + + // PowerSaving variant + // PowerSaving defaults + _prefs.powersaving_enabled = 1; + + // RXPS defaults - Balanced profile + _prefs.rxps.enabled = 1; + _prefs.rxps.level = 6; + _prefs.rxps.preamble = 16; } void SensorMesh::begin(FILESYSTEM* fs) { @@ -856,6 +888,8 @@ void SensorMesh::begin(FILESYSTEM* fs) { } radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + // PowerSaving variant + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); radio_driver.setTxPower(_prefs.tx_power_dbm); board.attachDynamicPrefs(_prefs.getCustom()); @@ -1018,12 +1052,16 @@ 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); + // PowerSaving variant + _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); + // PowerSaving variant + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); MESH_DEBUG_PRINTLN("Radio params restored"); } @@ -1114,3 +1152,10 @@ void SensorMesh::loop() { dirty_contacts_expiry = 0; } } + +// PowerSaving variant +// To check if there is pending work +bool SensorMesh::hasPendingWork() const { + return _mgr->getOutboundTotal() > 0 || _mgr->getInboundTotal() > 0 || // rxdelay packets wait in the inbound queue + isTransmitting() || _prefs.getRadioPrefs()->isRxPowerSavingCalibrationActive(); +} diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 590ca0c76..1ad3aa26c 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -52,6 +52,10 @@ public: void begin(FILESYSTEM* fs); void loop(); void handleCommand(ClientInfo* from, uint32_t sender_timestamp, char* command, char* reply); + + // PowerSaving variant + // To check if there is pending work + bool hasPendingWork() const; // CommonCLI callbacks const char* getFirmwareVer() override { return FIRMWARE_VERSION; } diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index 66991daad..3d6162293 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -52,6 +52,10 @@ void halt() { static char command[160]; +// PowerSaving variant +// For power saving +unsigned long POWERSAVING_FIRSTSLEEP_SECS = 120; // The first sleep (if enabled) from boot + void setup() { Serial.begin(115200); delay(1000); @@ -62,6 +66,13 @@ void setup() { external_watchdog.begin(); #endif +// PowerSaving variant +#if defined(MESH_DEBUG) && defined(NRF52_PLATFORM) + // give some extra time for serial to settle so + // boot debug messages can be seen on terminal + delay(5000); +#endif + #ifdef DISPLAY_CLASS if (display.begin()) { display.startFrame(); @@ -106,6 +117,16 @@ void setup() { command[0] = 0; + // PowerSaving variant +#if ENV_INCLUDE_GPS == 1 + // Apply PowerSaving profile for GPS + if (sensors.getLocationProvider() != NULL) { + // GPS on and off duration in seconds + sensors.getLocationProvider()->setPowerSavingProfile(the_mesh.getNodePrefs()->powersaving_enabled, 600, + 86400); // Max 10 minutes, 1 day + } +#endif + sensors.begin(); the_mesh.begin(fs); @@ -164,4 +185,20 @@ void loop() { #ifdef HAS_EXTERNAL_WATCHDOG external_watchdog.loop(); #endif + + // PowerSaving variant + if (the_mesh.getNodePrefs()->powersaving_enabled && !the_mesh.hasPendingWork()) { +#if defined(NRF52_PLATFORM) + board.sleep(0); // nrf ignores seconds param, sleeps whenever possible +#else + if (the_mesh.millisHasNowPassed(POWERSAVING_FIRSTSLEEP_SECS * 1000)) { // To check if it is time to sleep + board.sleep(30); // Sleep. Wake up after a while or when receiving a LoRa packet + } +#endif + } + + if (the_mesh.getNodePrefs()->reboot_interval > 0 && + the_mesh.millisHasNowPassed(the_mesh.getNodePrefs()->reboot_interval * 3600000)) { + board.reboot(); + } } From af23f950411a8bfd5c89a4e34dd9a62f918e6e0a Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Sun, 18 Oct 2026 21:48:46 +0700 Subject: [PATCH 28/34] PowerSaving for Room Servers --- examples/simple_room_server/MyMesh.cpp | 35 +++++++++++++++++++++++++- examples/simple_room_server/MyMesh.h | 4 +++ examples/simple_room_server/main.cpp | 25 ++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index da4c776c0..21301ac3d 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -681,8 +681,19 @@ 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; + +// PowerSaving variant +#ifdef RADIO_FEM_RXGAIN + _prefs.radio_fem_rxgain = RADIO_FEM_RXGAIN; +#else + _prefs.radio_fem_rxgain = 0; +#endif + +#ifdef RADIO_FEM_TXGAIN + _prefs.radio_fem_txgain = RADIO_FEM_TXGAIN; // board-specific default for PA +#else _prefs.radio_fem_txgain = 0; +#endif next_post_idx = 0; next_client_idx = 0; @@ -691,6 +702,15 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _num_posted = _num_post_pushes = 0; memset(default_scope.key, 0, sizeof(default_scope.key)); + + // PowerSaving variant + // PowerSaving defaults + _prefs.powersaving_enabled = 1; + + // RXPS defaults - Balanced profile + _prefs.rxps.enabled = 1; + _prefs.rxps.level = 6; + _prefs.rxps.preamble = 16; } void MyMesh::begin(FILESYSTEM *fs) { @@ -723,6 +743,8 @@ void MyMesh::begin(FILESYSTEM *fs) { } radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + // PowerSaving variant + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); radio_driver.setTxPower(_prefs.tx_power_dbm); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); @@ -1057,12 +1079,16 @@ 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); + // PowerSaving variant + _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); + // PowerSaving variant + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); MESH_DEBUG_PRINTLN("Radio params restored"); } @@ -1079,3 +1105,10 @@ void MyMesh::loop() { uptime_millis += now - last_millis; last_millis = now; } + +// PowerSaving variant +// To check if there is pending work +bool MyMesh::hasPendingWork() const { + 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.h b/examples/simple_room_server/MyMesh.h index 4dee8bdbf..b16686237 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -229,4 +229,8 @@ public: void clearStats() override; void handleCommand(uint32_t sender_timestamp, char* command, char* reply); void loop(); + + // PowerSaving variant + // To check if there is pending work + bool hasPendingWork() const; }; diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index 65fc55ef7..20d560b4c 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -26,6 +26,10 @@ static char command[MAX_POST_TEXT_LEN+1]; static char ethernet_command[MAX_POST_TEXT_LEN+1]; #endif +// PowerSaving variant +// For power saving +unsigned long POWERSAVING_FIRSTSLEEP_SECS = 120; // The first sleep (if enabled) from boot + void setup() { Serial.begin(115200); delay(1000); @@ -83,6 +87,16 @@ void setup() { ethernet_command[0] = 0; #endif + // PowerSaving variant +#if ENV_INCLUDE_GPS == 1 + // Apply PowerSaving profile for GPS + if (sensors.getLocationProvider() != NULL) { + // GPS on and off duration in seconds + sensors.getLocationProvider()->setPowerSavingProfile(the_mesh.getNodePrefs()->powersaving_enabled, 600, + 86400); // Max 10 minutes, 1 day + } +#endif + sensors.begin(); the_mesh.begin(fs); @@ -166,4 +180,15 @@ void loop() { #ifdef HAS_EXTERNAL_WATCHDOG external_watchdog.loop(); #endif + + // PowerSaving variant + if (the_mesh.getNodePrefs()->powersaving_enabled && !the_mesh.hasPendingWork()) { +#if defined(NRF52_PLATFORM) + board.sleep(0); // nrf ignores seconds param, sleeps whenever possible +#else + if (the_mesh.millisHasNowPassed(POWERSAVING_FIRSTSLEEP_SECS * 1000)) { // To check if it is time to sleep + board.sleep(5); // Sleep. Wake up after a while or when receiving a LoRa packet + } +#endif + } } From b522479d11532ae506ecb6e0ae7364e893f8d888 Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Thu, 22 Oct 2026 21:12:56 +0700 Subject: [PATCH 29/34] PowerSaving for companions --- examples/companion_radio/MyMesh.cpp | 45 +++++++++++++++- examples/companion_radio/NodePrefs.h | 38 +++++++++++++- examples/companion_radio/PowerSaving.h | 73 ++++++++++++++++++++++++++ examples/companion_radio/main.cpp | 28 +++++++++- src/Dispatcher.h | 5 ++ src/helpers/StaticPoolPacketManager.h | 3 ++ 6 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 examples/companion_radio/PowerSaving.h diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f32ea67ae..1fbdf1239 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -925,7 +925,14 @@ 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; + +// PowerSaving variant +#ifdef RADIO_FEM_RXGAIN + _prefs.radio_fem_rxgain = RADIO_FEM_RXGAIN; +#else + _prefs.radio_fem_rxgain = 0; +#endif + #ifdef RADIO_FEM_TXGAIN _prefs.radio_fem_txgain = RADIO_FEM_TXGAIN; // board-specific default for PA #else @@ -940,6 +947,15 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.rx_boosted_gain = 1; // enabled by default #endif #endif + + // PowerSaving variant + // PowerSaving defaults + _prefs.powersaving_enabled = 1; + + // RXPS defaults - Balanced profile + _prefs.rxps.enabled = 1; + _prefs.rxps.level = 6; + _prefs.rxps.preamble = 16; } void MyMesh::begin() { @@ -1003,6 +1019,10 @@ void MyMesh::begin() { _store->loadChannels(this); radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + + // PowerSaving variant + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); + radio_driver.setTxPower(_prefs.tx_power_dbm); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); @@ -1448,6 +1468,10 @@ void MyMesh::handleCmdFrame(size_t len) { savePrefs(); radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + + // PowerSaving variant + _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); @@ -1705,7 +1729,11 @@ void MyMesh::handleCmdFrame(size_t len) { telemetry.addTemperature(TELEM_CHANNEL_SELF, temperature); // Built-in MCU Temperature } + // PowerSaving variant // query other sensors -- target specific + if (sensors.getLocationProvider() != NULL && sensors.getLocationProvider()->isPowerSavingEnabled()) { + sensors.getLocationProvider()->syncTime(); // Request for GPS sync if GPS is in PowerSaving + } sensors.querySensors(0xFF, telemetry); int i = 0; @@ -2181,6 +2209,18 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* } #endif + // PowerSaving variant + 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; @@ -2448,7 +2488,8 @@ bool MyMesh::advert() { } } +// PowerSaving variant // 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/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 46a3dbb7c..f16fc22ad 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -37,6 +37,10 @@ public: 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) + + // PowerSaving variant + 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 @@ -48,6 +52,10 @@ public: char default_scope_name[31]; uint8_t default_scope_key[16]; int8_t tz_offset = 0; + + // PowerSaving variant + uint8_t powersaving_enabled = 0; // boolean + #ifdef ENABLE_WIFI_INTERFACE #ifndef WIFI_SSID #define WIFI_SSID "" @@ -81,10 +89,19 @@ private: def("agc_int", _parent->agc_reset_interval); def("hash_mode", _parent->path_hash_mode); def("multi_ack", _parent->multi_acks); + // PowerSaving variant + 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); } public: RadioPrefs(NodePrefs* parent) : _parent(parent) { } + // PowerSaving variant + RxPowerSavingConfig* getRxPowerSavingConfig() override { return &_parent->rxps; } + // CommonRadioPrefs interface float getFreq() const override { return _parent->freq; } void setFreq(float f) override { _parent->freq = f; markDirty(); } @@ -172,6 +189,18 @@ private: }; CompanionPrefs companion; + // PowerSaving variant + 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 @@ -200,13 +229,17 @@ protected: def("gps", gps); def("repeat", repeat); def("comp", companion); + + // PowerSaving variant + 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 @@ -220,6 +253,9 @@ public: void setRepeatEn(bool en) { repeat.disable_fwd = en ? 0 : 1; } CommonRadioPrefs* getRadioPrefs() { return &radio; } + // PowerSaving variant + const CommonRadioPrefs* getRadioPrefs() const { return &radio; } + KeyValueStore* getCustom() { return &custom; } bool isDirty() const override { return ConfigSerializer::isDirty() || radio.isDirty() || custom.isDirty(); } diff --git a/examples/companion_radio/PowerSaving.h b/examples/companion_radio/PowerSaving.h new file mode 100644 index 000000000..6ff120930 --- /dev/null +++ b/examples/companion_radio/PowerSaving.h @@ -0,0 +1,73 @@ +#pragma once + +#if defined(ESP32_PLATFORM) +// PowerSaving for companions +static void enableCompanionPowerSaving() { +#if defined(BLE_PIN_CODE) && !CONFIG_IDF_TARGET_ESP32C6 + // Enable BLE sleep + esp_bt_sleep_enable(); +#endif + + // Configure Power Management +#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; +#elif CONFIG_IDF_TARGET_ESP32C6 + esp_pm_config_t pm_config; +#endif + +#if (defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT) || defined(ENABLE_USB_INTERFACE) + // Disable automatic light sleep for USB CDC Serial and USB Companion + pm_config = { .max_freq_mhz = 80, .min_freq_mhz = 40, .light_sleep_enable = false }; +#else + pm_config = { .max_freq_mhz = 80, .min_freq_mhz = 40, .light_sleep_enable = true }; +#endif + + esp_err_t errPM = esp_pm_configure(&pm_config); + if (errPM == ESP_OK) { + Serial.println("Power Management configured successfully"); + } else { + Serial.printf("Power Management failed to configure: %d\r\n", errPM); + } +} + +static void disableCompanionPowerSaving() { + // Disable BLE sleep + esp_bt_sleep_disable(); + + // Configure Power Management +#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; +#elif CONFIG_IDF_TARGET_ESP32C6 + esp_pm_config_t pm_config; +#endif + +#if defined(ESP32_CPU_FREQ) + pm_config = { .max_freq_mhz = ESP32_CPU_FREQ, .min_freq_mhz = ESP32_CPU_FREQ, .light_sleep_enable = false }; +#else + pm_config = { .max_freq_mhz = 80, .min_freq_mhz = 80, .light_sleep_enable = false }; +#endif + + esp_err_t errPM = esp_pm_configure(&pm_config); + if (errPM == ESP_OK) { + Serial.println("Power Management configured successfully"); + } else { + Serial.printf("Power Management failed to configure: %d\r\n", errPM); + } +} + +static void applyCompanionPowerSaving(bool powersaving_enabled) { + if (the_mesh.getNodePrefs()->powersaving_enabled) { + enableCompanionPowerSaving(); + } else { + disableCompanionPowerSaving(); + } +} +#endif \ No newline at end of file diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 839d49583..c1ba967bd 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -2,6 +2,12 @@ #include #include "MyMesh.h" +#ifdef ESP32_PLATFORM +#include "esp_pm.h" +#include "esp_bt.h" +#include "PowerSaving.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; @@ -287,6 +293,10 @@ void setup() { #endif board.onBootComplete(); + +#if defined(ESP32_PLATFORM) + applyCompanionPowerSaving(the_mesh.getNodePrefs()->powersaving_enabled); +#endif } void loop() { @@ -302,12 +312,28 @@ void loop() { external_watchdog.loop(); #endif - if (!the_mesh.hasPendingWork()) { + if (the_mesh.getNodePrefs()->powersaving_enabled && !the_mesh.hasPendingWork()) { #if defined(NRF52_PLATFORM) board.sleep(0); // nrf ignores seconds param, sleeps whenever possible +#elif defined(ESP32_PLATFORM) + #if defined(BLE_PIN_CODE) + if (!bluetooth_interface.isReadBusy() && !bluetooth_interface.isWriteBusy()) { // BLE is not busy + vTaskDelay(pdMS_TO_TICKS(10)); // attempt to sleep + } + #elif defined(ENABLE_USB_INTERFACE) + vTaskDelay(pdMS_TO_TICKS(10)); // attempt to sleep + #endif #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(applied_powersaving); + } +#endif + #ifdef ENABLE_WIFI_INTERFACE if (wifi_enabled) { // RP2040 has no WiFi event callbacks, so poll the link state instead diff --git a/src/Dispatcher.h b/src/Dispatcher.h index aad6cba3e..3adb58ff6 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -98,6 +98,9 @@ public: virtual Packet* removeOutboundByIdx(int i) = 0; virtual void queueInbound(Packet* packet, uint32_t scheduled_for) = 0; virtual Packet* getNextInbound(uint32_t now) = 0; + + // PowerSaving variant + virtual int getInboundTotal() const { return 0; } // delayed (rxdelay) packets not yet processed }; typedef uint32_t DispatcherAction; @@ -173,6 +176,8 @@ protected: virtual unsigned long getDutyCycleWindowMs() const { return 3600000; } public: + // PowerSaving variant + bool isTransmitting() const { return outbound != nullptr; } void begin(); void loop(); diff --git a/src/helpers/StaticPoolPacketManager.h b/src/helpers/StaticPoolPacketManager.h index f63700e51..9d3b9d0ab 100644 --- a/src/helpers/StaticPoolPacketManager.h +++ b/src/helpers/StaticPoolPacketManager.h @@ -38,4 +38,7 @@ public: mesh::Packet* removeOutboundByIdx(int i) override; void queueInbound(mesh::Packet* packet, uint32_t scheduled_for) override; mesh::Packet* getNextInbound(uint32_t now) override; + + // PowerSaving variant + int getInboundTotal() const override { return rx_queue.count(); } }; \ No newline at end of file From d838bdbb929cea7869fc42e7905179d6d6fcb8d2 Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Thu, 22 Oct 2026 23:00:04 +0700 Subject: [PATCH 30/34] Changed first sleep for ESP32 repeaters with PowerSaving from 2 to 5 minutes --- examples/simple_repeater/main.cpp | 2 +- src/helpers/CommonCLI.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 299eb3ed8..5fe9b0efb 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -28,7 +28,7 @@ static char ethernet_command[160]; #endif // For power saving -unsigned long POWERSAVING_FIRSTSLEEP_SECS = 120; // The first sleep (if enabled) from boot +unsigned long POWERSAVING_FIRSTSLEEP_SECS = 300; // The first sleep (if enabled) from boot #if defined(PIN_USER_BTN) && defined(_SEEED_SENSECAP_SOLAR_H_) static unsigned long userBtnDownAt = 0; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 9de3c2a47..4c0dee97b 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -489,7 +489,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re _prefs->powersaving_enabled = 1; savePrefs(); // PowerSaving variant - strcpy(reply, "on - After 2 minutes. Serial console will be off."); + strcpy(reply, "on - After 5 minutes. Serial console will be off."); #elif defined(WITH_BRIDGE) strcpy(reply, "Bridge not supported"); #else From 468956909517f0da3159e9506a817bc830afef69 Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Thu, 22 Oct 2026 23:21:43 +0700 Subject: [PATCH 31/34] Left-aligned dots for companions --- examples/companion_radio/ui-new/UITask.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index f527baf58..6d727ff16 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -305,7 +305,8 @@ public: display.setColor(UIColor::title_bkg); } int y = 14; - int x = display.width() / 2 - 5 * (HomePage::Count-1); + // PowerSaving variant + int x = 5; // Left-aligned dots for (uint8_t i = 0; i < HomePage::Count; i++, x += 10) { if (i == _page) { display.fillRect(x-1, y-1, 4, 4); From bdd1d9e652590042b36e0c6979f8531cdc7bba27 Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Fri, 23 Oct 2026 00:29:27 +0700 Subject: [PATCH 32/34] Fixed missing BSEC library for RAK3401 --- variants/rak3401/platformio.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/variants/rak3401/platformio.ini b/variants/rak3401/platformio.ini index eddadd031..fc25478e9 100644 --- a/variants/rak3401/platformio.ini +++ b/variants/rak3401/platformio.ini @@ -31,6 +31,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 d2e84bc3ba3c106ac9642510f57ca07d8d0e6943 Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Fri, 23 Oct 2026 00:44:51 +0700 Subject: [PATCH 33/34] Fixed companion PS for C6 --- examples/companion_radio/PowerSaving.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/companion_radio/PowerSaving.h b/examples/companion_radio/PowerSaving.h index 6ff120930..6ee43f611 100644 --- a/examples/companion_radio/PowerSaving.h +++ b/examples/companion_radio/PowerSaving.h @@ -35,8 +35,10 @@ static void enableCompanionPowerSaving() { } static void disableCompanionPowerSaving() { +#if defined(BLE_PIN_CODE) && !CONFIG_IDF_TARGET_ESP32C6 // Disable BLE sleep esp_bt_sleep_disable(); +#endif // Configure Power Management #if CONFIG_IDF_TARGET_ESP32C3 From 485eefe5435067604ff248dc39ea466a124888a8 Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Fri, 23 Oct 2026 23:06:37 +0700 Subject: [PATCH 34/34] RXPS: Keep LF RC for LR1110 --- src/helpers/radiolib/CustomLR1110.h | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/helpers/radiolib/CustomLR1110.h b/src/helpers/radiolib/CustomLR1110.h index 4426d56ac..c00033d9e 100644 --- a/src/helpers/radiolib/CustomLR1110.h +++ b/src/helpers/radiolib/CustomLR1110.h @@ -60,13 +60,10 @@ class CustomLR1110 : public LR1110 { // 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(). + // Keep LF RC: LF XOSC conflicts with RadioLib's IRQ2 on DIO11. int16_t restoreLfClock() { return configLfClock( - RADIOLIB_LR11X0_LF_BUSY_RELEASE_DISABLED | RADIOLIB_LR11X0_LF_CLK_XOSC); + RADIOLIB_LR11X0_LF_BUSY_RELEASE_DISABLED | RADIOLIB_LR11X0_LF_CLK_RC); } // effectiveRxPeriod reports the RX window actually programmed, which may be