Merge PowerSaving-v18 into keymindCascade

Integrate the ESP32 power-saving core and RXPS recovery/status updates while preserving radio2, telemetry and saved settings.
This commit is contained in:
mikecarper committed 2026-10-23 16:11:49 -07:00
commit 9b0e013f4b
62 files changed
+1679 -139

No files matched your search

+2
View File
@@ -499,6 +499,8 @@ jobs:
python3 -B test/test_esp32_power_test_controls.py -v
python3 -B test/test_esp32_uart_quiet_session.py -v
python3 -B test/test_companion_rxps_getters.py -v
python3 -B test/test_rxps_runtime.py -v
python3 -B test/test_rxps_hardware_capability.py -v
python3 -B test/test_nrf52_sleep_cycle.py -v
python3 -B test/test_nrf52_sleep_events.py -v
python3 -B test/test_nrf52_profile_idle.py -v
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# ./build-iotthinks.sh
export FIRMWARE_VERSION="PowerSaving17.1.6"
export FIRMWARE_VERSION="PowerSaving18"
############# Repeaters #############
# Commonly-used boards
+15 -1
View File
@@ -1983,6 +1983,7 @@ Station G2/G3 targets default to `off`.
**Usage:**
- `get radio.rxps`
- `get radio.rxps.config`
- `get radio.rxps.status`
- `get radio.rxps.rfrx_disabled`
- `get rxps.wd`
- `set radio.rxps.rfrx_disabled <state>`
@@ -2015,6 +2016,15 @@ Station G2/G3 targets default to `off`.
- `get radio.rxps.config` adds the persisted level and preamble assumption to
the on/off and timing values. Deployment tools use it to restore a
level-based preference without converting it to fixed manual timings.
- `get radio.rxps.status` reports the saved `desired` state, actual `effective`
state (`armed` or `continuous`), hardware support, last driver error (`err`),
arm failures (`fail`), level, preamble and requested RX/sleep
periods in microseconds. Levels `9` and `10` carry `(!)` to mark experimental
settings. It includes `catch` for armed, level-based settings and
`erx`/`eslp` when the driver clamps the periods and the reply has room.
Three consecutive failed arm attempts trigger continuous receive as a
fallback. Unsupported radios stay in continuous receive. Saved preferences
remain intact.
- `radio.rxps.rfrx_disabled` is a runtime-only diagnostic setting and resets to `off` after reboot.
- Its default `off` state keeps the host-controlled SX1262 receive path enabled during RX duty-cycle mode. Setting it to `on` reproduces the old missing-RF_RX behavior and can significantly reduce receive sensitivity, making remote commands harder to receive.
- `radio.rxps.rfrx_disabled` is supported only on SX1262 targets with a host-controlled RX enable pin.
@@ -3085,6 +3095,10 @@ The G3 button also wakes the device for at least two minutes. See the
for verification steps and the distinction between the fix and the published
binaries.
ESP32 builds with button wake stay awake for at least 30 seconds after a press;
the G3 retains its longer two-minute hold. Repeaters wait five minutes after
startup before allowing sleep.
Companion firmware defaults this setting to `on`. Full Companion accepts the command from its local USB terminal and exposes the same setting in WebConfig. On ESP32 with processor speed set to Auto, it normally selects 80 MHz, enables idle yielding, and enables the configured GPS duty cycle. While a second radio profile is active, Auto holds the highest supported CPU speed for faster switching; SenseCAP Indicator retains its existing policy. Explicit processor speeds remain selected. USB and each active wireless transport remain available; SenseCAP Indicator Full keeps only its selected BLE or infrastructure-WiFi secondary transport active. `set powersaving off` restores the board's normal CPU clock and disables the GPS duty cycle. This device setting is separate from LoRa RXPS (`radio.rxps`) and WiFi modem power save (`wifi.powersave`). Infrastructure uses the same commands; its hardware and active-service sleep guards determine when the node can sleep.
#### Diagnose what prevents power saving
@@ -3177,7 +3191,7 @@ the X1 is not opted in without that hardware confirmation.
---
#### View or set the reboot interval (Repeater and room server)
#### View or set the reboot interval (Repeater, Room Server and Sensor)
**Usage:**
- `get reboot.interval`
- `set reboot.interval <hours>`
+66
View File
@@ -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"
}
]
}
}
}
```
+2 -1
View File
@@ -10451,7 +10451,8 @@ static bool isCompanionRadioPrefsCommand(const char* command) {
static const char* const exact_commands[] = {
"get radio", "get freq", "get af", "get dutycycle", "get tx",
"get rxdelay", "get path.hash.mode", "get multi.acks",
"get txdelay", "get direct.txdelay", "get int.thresh", "get agc.reset.interval"
"get txdelay", "get direct.txdelay", "get int.thresh", "get agc.reset.interval",
"get radio.rxps.status"
};
for (const char* candidate : exact_commands) {
if (strcmp(command, candidate) == 0) return true;
+10
View File
@@ -162,6 +162,11 @@ private:
def("agc_int", _parent->agc_reset_interval);
def("hash_mode", _parent->path_hash_mode);
def("multi_ack", _parent->multi_acks);
def("rxps_en", _parent->rx_powersaving_enabled);
def("rxps_rx_us", _parent->rx_ps_rx_us);
def("rxps_sleep_us", _parent->rx_ps_sleep_us);
def("rxps_level", _parent->rx_ps_level);
def("rxps_preamble", _parent->rx_ps_preamble);
def("usb_debug", _parent->usb_debug_enabled);
#ifdef TBEAM_1W
def("fan", _parent->fan_mode, sizeof(_parent->fan_mode));
@@ -172,6 +177,10 @@ private:
public:
explicit RadioPrefs(CompanionNodePrefs* parent) : _parent(parent) { }
RxPowerSavingConfig readRxPowerSavingConfig() const override {
return {_parent->rx_powersaving_enabled, _parent->rx_ps_rx_us,
_parent->rx_ps_sleep_us, _parent->rx_ps_level, _parent->rx_ps_preamble};
}
bool getByKey(const char* key, char* value, size_t max_len) override {
if (key != nullptr && strcmp(key, "usb_debug") == 0) {
@@ -319,6 +328,7 @@ public:
void setRepeatEn(bool enabled) { client_repeat = enabled ? 1 : 0; }
CommonRadioPrefs* getRadioPrefs() { return &radio; }
const CommonRadioPrefs* getRadioPrefs() const { return &radio; }
KeyValueStore* getCustom() { return &custom; }
bool isDirty() const {
+1 -3
View File
@@ -1060,9 +1060,7 @@ public:
display.setColor(UIColor::corp_blue);
}
int y = header_height + 2;
const int visible_pages = HomePage::Count
- (the_mesh.isDualRadioActive() ? 0 : 2);
int x = display.width() / 2 - 5 * (visible_pages - 1);
int x = 5;
for (uint8_t i = 0; i < HomePage::Count; i++) {
if (!isPageVisible(i)) continue;
if (i == _page) {
+5 -3
View File
@@ -3813,9 +3813,11 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
_prefs.rx_ps_preamble = DEFAULT_RXPS_PREAMBLE;
_prefs.rx_ps_rx_us = RX_POWERSAVING_DEFAULT_RX_US;
_prefs.rx_ps_sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US;
recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, _prefs.sf, _prefs.bw,
_prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us,
&_prefs.rx_ps_sleep_us);
if (WRAPPER_CLASS::SupportsRxPowerSaving) {
recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, _prefs.sf, _prefs.bw,
_prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us,
&_prefs.rx_ps_sleep_us);
}
// bridge defaults
#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \
+1 -1
View File
@@ -148,7 +148,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; // Leave five minutes for the startup console.
#if defined(PIN_USER_BTN) && defined(_SEEED_SENSECAP_SOLAR_H_)
static unsigned long userBtnDownAt = 0;
+4
View File
@@ -475,4 +475,8 @@ void loop() {
delay(1); // Keep USB and the button wake interval serviced while idle.
}
#endif
if (the_mesh.getNodePrefs()->reboot_interval > 0 &&
the_mesh.millisHasNowPassed(the_mesh.getNodePrefs()->reboot_interval * 3600000UL)) {
board.reboot();
}
}
+1
View File
@@ -74,6 +74,7 @@ nav:
- host_cli_service.md
- Radio, networking, and power:
- radio_profiles.md
- radio_presets.md
- fleet_management.md
- room_services.md
- carrier_wave.md
+7
View File
@@ -77,6 +77,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
; OTA is available on every ESP32 variant (A/B via esp_ota; the detools-sequential apply decodes into the
; inactive slot). The received .mota is staged in the INACTIVE OTA slot (OtaStoreFlashEsp32), not RAM, so
@@ -147,6 +150,8 @@ lib_deps =
[esp32c6_base]
extends = esp32_base
platform = https://github.com/pioarduino/platform-espressif32/releases/download/53.03.13-1/platform-espressif32.zip
; C6 needs Arduino 3.x; do not inherit the PowerSaving Arduino 2.x package.
platform_packages =
; ESP32-S3 Full Companion images use the ROM-compatible dual-I/O flash mode.
; This is independent of the board's PSRAM type and of whether the USB
@@ -528,6 +533,8 @@ build_src_filter =
+<../src/helpers/ArduinoSerialInterface.cpp>
+<../src/helpers/DynamicConfigSerializer.cpp>
+<../src/helpers/CommonRadioPrefs.cpp>
+<../src/helpers/radiolib/RXPowerSaving.cpp>
+<../src/helpers/radiolib/RXPowerSavingCLI.cpp>
+<../src/helpers/FleetCommand.cpp>
+<../src/helpers/ui/MomentaryButton.cpp>
lib_deps =
+23 -3
View File
@@ -5,6 +5,9 @@
#include "CarrierTools.h"
#include "PacketFileLoggingConfig.h"
#include "CommonCLI.h"
#ifdef WRAPPER_CLASS
#include "target.h"
#endif
#include "PrefsSaveReplyGuard.h"
#include "UsbLoggingWatchdog.h"
#include "PowerSavingDiagnostics.h"
@@ -466,6 +469,9 @@ static const char* retryPresetName(uint8_t preset) {
bool CommonCLI::calculateRxPowerSavingLevel(uint32_t level, uint8_t sf, float bw, uint32_t preamble,
uint32_t* rx_us, uint32_t* sleep_us) {
#ifdef WRAPPER_CLASS
if (!WRAPPER_CLASS::SupportsRxPowerSaving) return false;
#endif
if (level < 1 || level > 10 || preamble > 0xFF
|| !isRxPowerSavingPreamble((uint8_t)preamble)) {
return false;
@@ -477,6 +483,9 @@ bool CommonCLI::calculateRxPowerSavingLevel(uint32_t level, uint8_t sf, float bw
static void appendRxPowerSavingAdjustmentNote(char* reply, const NodePrefs* prefs,
uint8_t sf, float bw) {
#ifdef WRAPPER_CLASS
if (!WRAPPER_CLASS::SupportsRxPowerSaving) return;
#endif
if (!reply || !prefs || !prefs->rx_powersaving_enabled
|| prefs->rx_ps_level < 1 || prefs->rx_ps_level > 10) {
return;
@@ -520,6 +529,9 @@ static void appendRxPowerSavingAdjustmentNote(char* reply, const NodePrefs* pref
// radio SF/BW. No-op (returns false) for manual timings (rx_ps_level == 0).
// Lets level-based RX powersaving auto-retune when SF/BW change.
bool CommonCLI::recalculateRxPowerSavingFromLevel(NodePrefs* prefs) {
#ifdef WRAPPER_CLASS
if (!WRAPPER_CLASS::SupportsRxPowerSaving) return false;
#endif
return recalcRxPowerSavingFromLevel(prefs->rx_ps_level, prefs->sf, prefs->bw,
prefs->rx_ps_preamble, &prefs->rx_ps_rx_us,
&prefs->rx_ps_sleep_us);
@@ -1795,9 +1807,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
_prefs->rx_ps_preamble = 0; // 0 = auto (derive from SF)
}
ensureRxPowerSavingDefaults(&_prefs->rx_ps_rx_us, &_prefs->rx_ps_sleep_us);
recalcRxPowerSavingFromLevel(_prefs->rx_ps_level, _prefs->sf, _prefs->bw, _prefs->rx_ps_preamble,
&_prefs->rx_ps_rx_us,
&_prefs->rx_ps_sleep_us); // retune level-based timings to the loaded SF/BW
recalculateRxPowerSavingFromLevel(_prefs); // retune supported level-based timings to loaded SF/BW
// Read the footer independently of optional legacy UART tails/profile.
#if defined(RAK_4631)
@@ -4739,6 +4749,14 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
return;
}
using mesh::cli::RxPowerSavingArgumentMode;
#ifdef WRAPPER_CLASS
if (!WRAPPER_CLASS::SupportsRxPowerSaving) {
if (parsed.mode != RxPowerSavingArgumentMode::Off) {
strcpy(reply, "ERROR: RX powersaving unsupported");
return;
}
}
#endif
if (parsed.mode == RxPowerSavingArgumentMode::Off) {
enable = 0;
} else if (parsed.mode == RxPowerSavingArgumentMode::Manual) {
@@ -5880,6 +5898,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
} else {
sprintf(reply, "> %s", _callbacks->isRxPowerSavingRfRxDisabled() ? "on" : "off");
}
} else if (strcmp(config, "radio.rxps.status") == 0) {
_prefs->getRadioPrefs()->formatRxPowerSavingStatus(reply, 157);
} else if (strcmp(config, "radio.rxps.config") == 0) {
ensureRxPowerSavingDefaults(&_prefs->rx_ps_rx_us, &_prefs->rx_ps_sleep_us);
sprintf(reply, "> %s,level=%u,preamble=%u,rx=%lu,sleep=%lu",
+11
View File
@@ -316,10 +316,19 @@ private:
def("agc_int", _parent->agc_reset_interval);
def("hash_mode", _parent->path_hash_mode);
def("multi_ack", _parent->multi_acks);
def("rxps_en", _parent->rx_powersaving_enabled);
def("rxps_rx_us", _parent->rx_ps_rx_us);
def("rxps_sleep_us", _parent->rx_ps_sleep_us);
def("rxps_level", _parent->rx_ps_level);
def("rxps_preamble", _parent->rx_ps_preamble);
}
public:
explicit RadioPrefs(NodePrefs* parent) : _parent(parent) { }
RxPowerSavingConfig readRxPowerSavingConfig() const override {
return {_parent->rx_powersaving_enabled, _parent->rx_ps_rx_us,
_parent->rx_ps_sleep_us, _parent->rx_ps_level, _parent->rx_ps_preamble};
}
// CommonRadioPrefs interface
float getFreq() const override { return _parent->freq; }
void setFreq(float f) override { _parent->freq = f; markDirty(); }
@@ -408,6 +417,7 @@ private:
void structure() override {
def("adc_mult", _parent->adc_multiplier);
def("pwr_sav_en", _parent->powersaving_enabled);
def("pwr_reboot_int", _parent->reboot_interval);
#if defined(ESP32_PLATFORM)
def("cpu_freq", _parent->cpu_freq_mhz);
#endif
@@ -478,6 +488,7 @@ public:
repeat(this), room(this), custom(&radio) { }
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(); }
+28
View File
@@ -1,6 +1,7 @@
#include "CommonRadioPrefs.h"
#include "TxtDataHelpers.h"
#include "target.h"
#include "radiolib/RXPowerSavingCLI.h"
#include <math.h>
#include <stdio.h>
@@ -179,10 +180,37 @@ bool CommonRadioPrefs::setByKey(const char* key, const char* value) {
return false;
}
void CommonRadioPrefs::formatRxPowerSavingStatus(char* reply, size_t reply_size) const {
auto config = readRxPowerSavingConfig();
uint8_t sf = getSpreadFactor();
float bw = getBandwidth();
uint16_t wire_preamble = 0;
RxPowerSavingStatus status;
#ifdef WRAPPER_CLASS
// The primary profile includes an active tempradio lease. A status read
// must describe that hardware timing without modifying saved settings.
const auto* profiles = radio_driver.profiles();
if (profiles && profiles->primary.sf >= 5 && profiles->primary.sf <= 12
&& profiles->primary.bw > 0.0f) {
sf = profiles->primary.sf;
bw = profiles->primary.bw;
}
radio_driver.getRxPowerSavingTimings(config.rx_us, config.sleep_us);
wire_preamble = radio_driver.currentPreambleLength();
status = radio_driver.getRxPowerSavingStatus();
#endif
RXPowerSavingCLI::get(config, status, sf, bw, reply, reply_size, wire_preamble);
}
bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) {
(void)sender_timestamp;
if (command == nullptr || reply == nullptr) return false;
if (strcmp(command, "get radio.rxps.status") == 0) {
formatRxPowerSavingStatus(reply, 160);
return true;
}
if (strcmp(command, "get radio") == 0) {
char freq[16], bw[16];
snprintf(freq, sizeof(freq), "%s", StrHelper::ftoa(getFreq()));
+4
View File
@@ -1,6 +1,7 @@
#pragma once
#include "ConfigSerializer.h"
#include "KeyValueStore.h"
#include "radiolib/RXPowerSaving.h"
class CommonRadioPrefs : public ConfigSerializer, public KeyValueStore {
bool _is_dirty = false;
@@ -62,6 +63,9 @@ public:
virtual uint8_t getFEMTxGain() const = 0;
virtual void setFEMTxGain(uint8_t g) = 0;
virtual RxPowerSavingConfig readRxPowerSavingConfig() const { return {}; }
void formatRxPowerSavingStatus(char* reply, size_t reply_size) 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
+17
View File
@@ -51,6 +51,10 @@ class ESP32Board : public mesh::MainBoard {
protected:
uint8_t startup_reason;
bool inhibit_sleep = false;
#if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) && MOMENTARY_BUTTON_WAKE_FROM_SLEEP
bool button_wake_hold_active = false;
uint32_t button_wake_at = 0;
#endif
bool ota_started_ap = false;
bool ota_started_radio = false;
bool companion_cpu_clock_policy = false;
@@ -413,6 +417,12 @@ public:
bool keep_awake = inhibit_sleep
|| hold(usb_sleep_held, Reason::UsbSession, 1)
|| hold(isRadioTestActive(), Reason::Board, 2);
#if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) && MOMENTARY_BUTTON_WAKE_FROM_SLEEP
if (button_wake_hold_active) {
button_wake_hold_active = (uint32_t)(millis() - button_wake_at) < 30000UL;
keep_awake = hold(button_wake_hold_active, Reason::Button) || keep_awake;
}
#endif
#if !defined(ARDUINO_USB_CDC_ON_BOOT) || !ARDUINO_USB_CDC_ON_BOOT
// External USB-to-UART chips cannot report host attachment here. Light
// sleep loses their first input bytes; UART wake loses the wake character
@@ -541,6 +551,13 @@ public:
gpio_wakeup_disable((gpio_num_t)button_gpio);
// ESP32 MomentaryButton polls; only its sleep wake source is temporary.
gpio_set_intr_type((gpio_num_t)button_gpio, GPIO_INTR_DISABLE);
#if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) && MOMENTARY_BUTTON_WAKE_FROM_SLEEP
if (gpio_get_level((gpio_num_t)button_gpio) == USER_BTN_PRESSED) {
// Keep the UI responsive after the wake press has been released.
button_wake_at = millis();
button_wake_hold_active = true;
}
#endif
}
// Enable CPU interrupt servicing
+2
View File
@@ -41,6 +41,8 @@ public:
void onSendFinished() override;
bool isInRecvMode() const override;
uint32_t getCADBusyCount() const { return 0; } // ESP-NOW has no LoRa CAD
uint32_t getPacketsRecv() const { return n_recv; }
uint32_t getPacketsSent() const { return n_sent; }
uint32_t getPacketsRecvErrors() const { return n_recv_errors; }
+16 -1
View File
@@ -29,6 +29,7 @@ class CustomLR1110 : public LR1110 {
uint32_t _activityAt = 0;
bool _headerSeen = false;
bool _rx_boosted = false;
bool _lfClockRestoreNeeded = false;
LR1110ProfileSwitchState _profileSwitch{MC_LR1110_FAST_PROFILE_SWITCH != 0};
uint8_t _profileSwitchFeatures = profileSwitchCapabilities();
bool _profileStandbyWarm = false;
@@ -381,10 +382,22 @@ class CustomLR1110 : public LR1110 {
}
float getFreqMHz() const { return freqMHz; }
uint32_t getTcxoDelay() const { return tcxoDelay; }
// DIO11 also carries RadioLib's IRQ2, so retain the LF RC clock.
bool needsLfClockRestore() const { return _lfClockRestoreNeeded; }
int16_t restoreLfClock() {
if (!_lfClockRestoreNeeded) return RADIOLIB_ERR_NONE;
const int16_t state = configLfClock(
RADIOLIB_LR11X0_LF_BUSY_RELEASE_DISABLED | RADIOLIB_LR11X0_LF_CLK_RC);
if (state == RADIOLIB_ERR_NONE) _lfClockRestoreNeeded = false;
return state;
}
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) {
RadioLibIrqFlags_t irqMask = RADIOLIB_IRQ_RX_DEFAULT_MASK,
uint32_t* effectiveRxPeriod = nullptr) {
uint32_t symbolPeriod = (uint32_t)(((1000.0f * (float)(1UL << this->spreadingFactor)) /
this->bandwidthKhz) + 0.999f);
uint32_t transitionTime = this->tcxoDelay + 1000;
@@ -402,6 +415,7 @@ class CustomLR1110 : public LR1110 {
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 =
@@ -419,6 +433,7 @@ class CustomLR1110 : public LR1110 {
// before SetRxDutyCycle. RadioLib does neither in its LoRa RXPS path.
int16_t state = standby(RADIOLIB_LR11X0_STANDBY_RC);
RADIOLIB_ASSERT(state);
_lfClockRestoreNeeded = true;
state = configLfClock(
RADIOLIB_LR11X0_LF_CLK_RC | RADIOLIB_LR11X0_LF_BUSY_RELEASE_ENABLED);
RADIOLIB_ASSERT(state);
+25 -44
View File
@@ -13,6 +13,10 @@ class CustomLR1110Wrapper : public RadioLibWrapper {
DeepInitCallback _deep_init;
public:
static constexpr bool SupportsRxPowerSaving = true;
uint32_t rxPowerSavingTransitionUs() const override {
return static_cast<CustomLR1110*>(_radio)->getTcxoDelay() + 1000UL;
}
uint8_t regulatorModes() const override { return static_cast<CustomLR1110*>(_radio)->regulatorModes(); }
mesh::power::RegulatorMode defaultRegulatorMode() const override {
return static_cast<CustomLR1110*>(_radio)->defaultRegulatorMode();
@@ -135,6 +139,7 @@ public:
}
bool isReceivingPacket() override {
if (_rx_ps_armed && isChipBusy()) return false;
return ((CustomLR1110 *)_radio)->isReceiving();
}
bool isChipBusy() override {
@@ -156,10 +161,11 @@ public:
_radio->setPreambleLength(currentPreambleLength()); // overcomes weird issues with small and big pkts
}
bool supportsRxPowerSaving() const override { return true; }
bool supportsRxPowerSaving() const override { return SupportsRxPowerSaving; }
void onReceiveProcessed() override { finishReceiveProcessing(); }
protected:
int startReceiveMode() override { return startReceivePowerSaving(); }
bool isPacketReady() override {
// Header errors are recovery events, not packets. Handle them before the
// generic duty-cycle readiness check can discard the IRQ or a stale buffer
@@ -178,51 +184,26 @@ protected:
return RadioLibWrapper::isPacketReady();
}
int startReceiveMode() override {
// Do not abort a frame that started while a calibration transition was
// being scheduled. recvRaw() will move it to continuous RX afterward.
if (_nf_calib_active && _rx_ps_armed && isPacketPendingOrReceiving()) {
return RADIOLIB_ERR_NONE;
}
if (_rx_ps_armed) {
// stop the previous duty-cycle sequence with an explicit standby before
// reconfiguring the radio
stopReceiveDutyCycle();
}
if (!_rx_ps_enabled || _nf_calib_active) {
// plain continuous RX: powersaving off, or a periodic noise-floor
// calibration window is in progress
if (!_rx_ps_enabled) _rx_ps_continuous_fallback = false;
return _radio->startReceive();
}
if (rxPowerSavingUsesContinuousFallback(_rx_ps_rx_us, _rx_ps_sleep_us)) {
_rx_ps_continuous_fallback = true;
return _radio->startReceive();
}
_rx_ps_continuous_fallback = false;
const RadioLibIrqFlags_t irqFlags = RADIOLIB_IRQ_RX_DEFAULT_FLAGS;
// route RX timeout (false preamble detect) and error IRQs to the IRQ pin
// as well, so the wrapper re-arms RX instead of waiting forever
const RadioLibIrqFlags_t irqMask =
(1UL << RADIOLIB_IRQ_RX_DONE) |
(1UL << RADIOLIB_IRQ_TIMEOUT) |
(1UL << RADIOLIB_IRQ_CRC_ERR) |
(1UL << RADIOLIB_IRQ_HEADER_ERR);
int err = ((CustomLR1110 *)_radio)->startReceiveDutyCycle(_rx_ps_rx_us, _rx_ps_sleep_us, irqFlags, irqMask);
if (err == RADIOLIB_ERR_NONE) {
_rx_ps_armed = true;
return err;
}
_rx_ps_continuous_fallback = true;
MESH_DEBUG_PRINTLN("CustomLR1110Wrapper: error: startReceiveDutyCycle(%d), falling back to continuous RX", err);
return _radio->startReceive();
int16_t armDutyCycle(RadioLibIrqFlags_t irq_flags, RadioLibIrqFlags_t irq_mask,
uint32_t* effective_rx_us, uint32_t* effective_sleep_us) override {
*effective_sleep_us = _rx_ps_sleep_us;
return ((CustomLR1110*)_radio)->startReceiveDutyCycle(
_rx_ps_rx_us, _rx_ps_sleep_us, irq_flags, irq_mask, effective_rx_us);
}
int16_t stopDutyCycleHardware() override {
const int16_t standby_error = _radio->standby();
const int16_t clock_error = ((CustomLR1110*)_radio)->restoreLfClock();
return standby_error != RADIOLIB_ERR_NONE ? standby_error : clock_error;
}
int16_t startContinuousReceive() override {
auto* radio = static_cast<CustomLR1110*>(_radio);
if (!radio->needsLfClockRestore()) return _radio->startReceive();
int16_t clock_error = _radio->standby();
if (clock_error == RADIOLIB_ERR_NONE) clock_error = radio->restoreLfClock();
return clock_error == RADIOLIB_ERR_NONE ? _radio->startReceive() : clock_error;
}
bool radioDeepInit() override {
return _deep_init != NULL && prepareRadioHardReset() && _deep_init()
&& _board->finishRadioHardReset();
+1
View File
@@ -74,6 +74,7 @@ class CustomSX1262 : public SX1262 {
bool _tcxoWakePending = false;
public:
uint32_t getTcxoDelay() const { return tcxoDelay; }
CustomSX1262(Module *mod) : SX1262(mod) { }
uint8_t regulatorModes() const { return _regulator.modes(); }
+18 -49
View File
@@ -53,6 +53,10 @@ protected:
}
public:
static constexpr bool SupportsRxPowerSaving = true;
uint32_t rxPowerSavingTransitionUs() const override {
return static_cast<CustomSX1262*>(_radio)->getTcxoDelay() + 1000UL;
}
uint8_t regulatorModes() const override { return static_cast<CustomSX1262*>(_radio)->regulatorModes(); }
mesh::power::RegulatorMode defaultRegulatorMode() const override {
return static_cast<CustomSX1262*>(_radio)->defaultRegulatorMode();
@@ -182,6 +186,7 @@ public:
}
bool isReceivingPacket() override {
if (_rx_ps_armed && isChipBusy()) return false;
return ((CustomSX1262 *)_radio)->isReceiving();
}
bool isChipBusy() override {
@@ -198,11 +203,12 @@ public:
uint8_t getSpreadingFactor() const override { return ((CustomSX1262 *)_radio)->spreadingFactor; }
void powerOff() {
if (isCarrierWaveActive()) setCarrierWave(0, 0);
if (_rx_ps_armed) stopReceiveDutyCycle();
((CustomSX1262 *)_radio)->standby();
((CustomSX1262 *)_radio)->sleep(false);
}
bool supportsRxPowerSaving() const override { return true; }
bool supportsRxPowerSaving() const override { return SupportsRxPowerSaving; }
void onReceiveProcessed() override { finishReceiveProcessing(); }
bool supportsRxPowerSavingRfRxDisable() const override {
@@ -261,57 +267,20 @@ public:
}
protected:
int startReceiveMode() override {
// Do not abort a frame that started while a calibration transition was
// being scheduled. recvRaw() will move it to continuous RX afterward.
if (_nf_calib_active && _rx_ps_armed && isPacketPendingOrReceiving()) {
return RADIOLIB_ERR_NONE;
}
if (_rx_ps_armed) {
// leaving duty-cycle mode (after RxDone or a reconfig): stop the
// sequencer and the still-running RTC, or its pending event can
// silently abort the RX we are about to start
stopReceiveDutyCycle();
}
if (!_rx_ps_enabled || _nf_calib_active) {
// plain continuous RX: powersaving off, or a periodic noise-floor
// calibration window is in progress
if (!_rx_ps_enabled) _rx_ps_continuous_fallback = false;
return _radio->startReceive();
}
if (!((CustomSX1262 *)_radio)->canUseRxPowerSavingDutyCycle(
_rx_ps_rx_us, _rx_ps_sleep_us)) {
_rx_ps_continuous_fallback = true;
return _radio->startReceive();
}
_rx_ps_continuous_fallback = false;
const RadioLibIrqFlags_t irqFlags = RADIOLIB_IRQ_RX_DEFAULT_FLAGS;
const RadioLibIrqFlags_t irqMask =
(1UL << RADIOLIB_IRQ_RX_DONE) |
(1UL << RADIOLIB_IRQ_TIMEOUT) |
(1UL << RADIOLIB_IRQ_CRC_ERR) |
(1UL << RADIOLIB_IRQ_HEADER_ERR);
int err = ((CustomSX1262 *)_radio)->startReceiveDutyCycle(_rx_ps_rx_us, _rx_ps_sleep_us, irqFlags, irqMask);
if (err == RADIOLIB_ERR_NONE) {
_rx_ps_armed = true;
return err;
}
_rx_ps_continuous_fallback = true;
MESH_DEBUG_PRINTLN("CustomSX1262Wrapper: error: startReceiveDutyCycle(%d), falling back to continuous RX", err);
return _radio->startReceive();
int startReceiveMode() override { return startReceivePowerSaving(); }
int16_t armDutyCycle(RadioLibIrqFlags_t irq_flags, RadioLibIrqFlags_t irq_mask,
uint32_t* effective_rx_us, uint32_t* effective_sleep_us) override {
*effective_rx_us = _rx_ps_rx_us;
*effective_sleep_us = _rx_ps_sleep_us;
return ((CustomSX1262*)_radio)->startReceiveDutyCycle(
_rx_ps_rx_us, _rx_ps_sleep_us, irq_flags, irq_mask);
}
void stopReceiveDutyCycle() override {
_radio->standby(); // also wakes the chip if it is in the sleep window
((CustomSX1262 *)_radio)->stopRTC();
_rx_ps_armed = false;
int16_t stopDutyCycleHardware() override {
const int16_t standby_error = _radio->standby();
const int16_t rtc_error = ((CustomSX1262*)_radio)->stopRTC();
return standby_error != RADIOLIB_ERR_NONE ? standby_error : rtc_error;
}
bool radioDeepInit() override {
// A board hook takes precedence over direct NRST because it may also need
// to recover a regulator, SPI bus, or I/O expander. With neither mechanism
+41
View File
@@ -0,0 +1,41 @@
#include "RXPowerSaving.h"
#include <math.h>
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
|| bw > 1000000.0f || !(capture_cost_symbols > 0.0f)
|| !isfinite(capture_cost_symbols) || transition_us >= RX_POWERSAVING_MAX_PERIOD_US) 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 && !isRxPowerSavingPreamble(proposed.preamble)) {
proposed.preamble = RX_POWERSAVING_PROFILE_PREAMBLE;
}
if (proposed.level == 0) {
if (proposed.rx_us < RX_POWERSAVING_MIN_MANUAL_PERIOD_US ||
proposed.sleep_us < RX_POWERSAVING_MIN_MANUAL_PERIOD_US ||
!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,
nullptr, nullptr, transition_us, capture_cost_symbols)) {
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;
}
+78 -7
View File
@@ -1,5 +1,6 @@
#pragma once
#include <Arduino.h>
#include <stdint.h>
#include <math.h>
#define RX_POWERSAVING_DEFAULT_RX_US 65625UL
#define RX_POWERSAVING_DEFAULT_SLEEP_US 60000UL
@@ -345,7 +346,9 @@ inline bool calcRxPowerSavingLevel(uint8_t level, uint8_t sf, float bw, uint8_t
uint32_t transition_us = rxPowerSavingDefaultTransitionUs()) {
if (rx_us == nullptr || sleep_us == nullptr || level < 1 ||
level > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL || sf < 5 || sf > 12 ||
!(bw > 0.0f) || bw > 1000000.0f || !isRxPowerSavingPreamble(preamble)) {
!(bw > 0.0f) || bw > 1000000.0f || !isRxPowerSavingPreamble(preamble)
|| !(capture_cost_symbols > 0.0f) || !(capture_cost_symbols < preamble)
|| transition_us >= RX_POWERSAVING_MAX_PERIOD_US) {
return false;
}
const bool unguarded = isRxPowerSavingUnguardedLevel(level);
@@ -466,10 +469,12 @@ inline bool calcRxPowerSavingLevelAtOrAbove(
uint8_t requested_level, uint8_t sf, float bw, uint8_t preamble,
uint32_t transition_us, uint32_t *rx_us, uint32_t *sleep_us,
uint8_t *effective_level = nullptr,
uint8_t *effective_preamble = nullptr) {
uint8_t *effective_preamble = nullptr,
float capture_cost_symbols = rxPowerSavingDefaultCaptureCost()) {
if (!rx_us || !sleep_us || requested_level < 1 || requested_level > 10
|| sf < 5 || sf > 12 || !(bw > 0.0f) || bw > 1000000.0f
|| !isRxPowerSavingPreamble(preamble)) {
|| !isRxPowerSavingPreamble(preamble) || !(capture_cost_symbols > 0.0f)
|| !(capture_cost_symbols < preamble) || transition_us >= RX_POWERSAVING_MAX_PERIOD_US) {
return false;
}
@@ -491,7 +496,7 @@ inline bool calcRxPowerSavingLevelAtOrAbove(
uint32_t candidate_rx_us = 0, candidate_sleep_us = 0;
if (!calcRxPowerSavingLevel(level, sf, bw, candidate_preamble,
&candidate_rx_us, &candidate_sleep_us,
rxPowerSavingDefaultCaptureCost(), transition_us)) continue;
capture_cost_symbols, transition_us)) continue;
// The catch assumption can be conservative, but the receive timeout
// must still cover the entire preamble our senders actually transmit.
@@ -544,7 +549,8 @@ inline bool recalcRxPowerSavingFromLevel(uint8_t level, uint8_t sf, float bw, ui
uint32_t *rx_ps_rx_us, uint32_t *rx_ps_sleep_us,
uint8_t *effective_level = nullptr,
uint8_t *effective_preamble = nullptr,
uint32_t transition_us = rxPowerSavingDefaultTransitionUs()) {
uint32_t transition_us = rxPowerSavingDefaultTransitionUs(),
float capture_cost_symbols = rxPowerSavingDefaultCaptureCost()) {
if (level < 1 || level > 10) {
return false; // manual: nothing to recompute
}
@@ -556,7 +562,7 @@ inline bool recalcRxPowerSavingFromLevel(uint8_t level, uint8_t sf, float bw, ui
uint32_t rx_us, sleep_us;
if (!calcRxPowerSavingLevelAtOrAbove(
level, sf, bw, preamble, transition_us, &rx_us, &sleep_us,
effective_level, effective_preamble)) {
effective_level, effective_preamble, capture_cost_symbols)) {
return false;
}
@@ -569,3 +575,68 @@ inline bool recalcRxPowerSavingFromLevel(uint8_t level, uint8_t sf, float bw, ui
return true;
}
// Runtime RXPS control is separate from the persisted role-specific layout.
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 (canAttempt()) ++_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;
constexpr RxPowerSavingConfig() = default;
constexpr RxPowerSavingConfig(uint8_t enabled_value, uint32_t rx_value,
uint32_t sleep_value, uint8_t level_value, uint8_t preamble_value)
: enabled(enabled_value), rx_us(rx_value), sleep_us(sleep_value),
level(level_value), preamble(preamble_value) { }
};
struct RxPowerSavingStatus {
bool supported = false;
bool armed = false;
int16_t last_error = 0;
uint32_t arm_failures = 0;
uint32_t effective_rx_us = 0;
uint32_t effective_sleep_us = 0;
};
class RxPowerSavingControl {
protected:
~RxPowerSavingControl() = default;
public:
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 float rxPowerSavingCaptureCostSymbols() const { return rxPowerSavingDefaultCaptureCost(); }
virtual uint32_t rxPowerSavingTransitionUs() const { return rxPowerSavingDefaultTransitionUs(); }
};
inline uint8_t rxPowerSavingPreambleForSF(uint8_t sf) { return sf <= 8 ? 32 : 16; }
inline bool isRxPowerSavingNumeric(const char* value) { return isNumeric(value); }
inline float rxPowerSavingCaptureCost(const RxPowerSavingControl* control) {
return control ? control->rxPowerSavingCaptureCostSymbols() : rxPowerSavingDefaultCaptureCost();
}
inline uint32_t rxPowerSavingTransition(const RxPowerSavingControl* control) {
return control ? control->rxPowerSavingTransitionUs() : rxPowerSavingDefaultTransitionUs();
}
bool normalizeRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw,
float capture_cost_symbols = rxPowerSavingDefaultCaptureCost(),
uint32_t transition_us = rxPowerSavingDefaultTransitionUs());
bool applyRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw,
RxPowerSavingControl* control);
static constexpr int16_t RX_POWERSAVING_ERR_TIMING = -10001;
+120
View File
@@ -0,0 +1,120 @@
#include "RXPowerSavingCLI.h"
#include "../CLICommandUtils.h"
#include <math.h>
#include <stdio.h>
#include <string.h>
static const char* rxPowerSavingLevelTag(uint8_t level) {
if (isRxPowerSavingRiskyWorkingMaxLevel(level)) return "(riskyWorkingMax)";
if (isRxPowerSavingOverdriveLevel(level)) return "(overdrive)";
return "";
}
bool RXPowerSavingCLI::set(const char* value, uint8_t sf, float bw,
RxPowerSavingConfig* config, RxPowerSavingControl* control,
char* reply, size_t reply_size) {
if (!config || !reply || !reply_size) return false;
mesh::cli::RxPowerSavingArguments args = {};
if (!mesh::cli::parseRxPowerSavingArgumentsStrict(value, args)) {
snprintf(reply, reply_size, "ERROR: use off|on|conservative|balanced|max|overdrive|riskyWorkingMax|level <1-10>|<rx_us> <sleep_us>");
return false;
}
RxPowerSavingConfig proposed = *config;
using Mode = mesh::cli::RxPowerSavingArgumentMode;
if (args.mode == Mode::Off) {
proposed.enabled = 0;
} else if (args.mode == Mode::Manual) {
proposed.enabled = 1;
proposed.level = 0;
proposed.preamble = 0;
proposed.rx_us = args.rx_us;
proposed.sleep_us = args.sleep_us;
} else {
proposed.enabled = 1;
proposed.level = args.mode == Mode::Conservative ? RX_POWERSAVING_CONSERVATIVE_LEVEL
: args.mode == Mode::Balanced ? RX_POWERSAVING_BALANCED_LEVEL : args.level;
const uint32_t preamble = args.mode == Mode::Level ? args.preamble : RX_POWERSAVING_PROFILE_PREAMBLE;
if (args.level > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL ||
proposed.level < 1 || proposed.level > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL ||
(preamble != 0 && (preamble > 128 || !isRxPowerSavingPreamble(preamble)))) {
snprintf(reply, reply_size, "ERROR: level range is 1-10; preamble is 16,32,64,128");
return false;
}
proposed.preamble = static_cast<uint8_t>(preamble);
}
if (proposed.enabled && !normalizeRxPowerSavingConfig(&proposed, sf, bw,
rxPowerSavingCaptureCost(control), rxPowerSavingTransition(control))) {
snprintf(reply, reply_size, "ERROR: invalid RXPS timing or SF/BW");
return false;
}
if (control ? !control->setRxPowerSaving(proposed.enabled != 0, proposed.rx_us, proposed.sleep_us)
: proposed.enabled != 0) {
snprintf(reply, reply_size, "ERROR: RX powersaving unsupported or radio busy");
return false;
}
if (control) control->setRxPowerSavingConfigError(false);
*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,
uint16_t wire_preamble) {
if (config == nullptr) return;
get(*config, control ? control->getRxPowerSavingStatus() : RxPowerSavingStatus{},
sf, bw, reply, reply_size, wire_preamble);
}
void RXPowerSavingCLI::get(const RxPowerSavingConfig& config,
const RxPowerSavingStatus& status,
uint8_t sf, float bw, char* reply, size_t reply_size,
uint16_t wire_preamble) {
if (reply == nullptr || reply_size == 0) return;
// 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.supported && status.armed && status.last_error == 0 && isValidRxPowerSavingPeriod(config.sleep_us) &&
config.level >= 1 && config.level <= RX_POWERSAVING_GUARDED_LEVELS &&
sf >= 5 && sf <= 12 && bw > 0.0f && bw <= 500.0f) {
const uint16_t preamble = wire_preamble ? wire_preamble : rxPowerSavingPreambleForParams(sf, bw);
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);
// Validated 30-second periods, SF5+ and these radio-family bandwidths
// bound the result well inside int32, including a 65535-symbol override.
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,
isRxPowerSavingUnguardedLevel(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);
}
}
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <stddef.h>
#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,
uint16_t wire_preamble = 0);
static void get(const RxPowerSavingConfig& config, const RxPowerSavingStatus& status,
uint8_t sf, float bw, char* reply, size_t reply_size,
uint16_t wire_preamble = 0);
};
+74 -3
View File
@@ -588,6 +588,8 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw,
else _rx_ps_enabled = true;
_rx_ps_rx_us = rx_ps_timings[0];
_rx_ps_sleep_us = rx_ps_timings[1];
_rx_ps_arm_retry.reset();
_rx_ps_last_error = RADIOLIB_ERR_NONE;
_rx_ps_continuous_fallback = rxPowerSavingUsesContinuousFallback(
_rx_ps_rx_us, _rx_ps_sleep_us);
}
@@ -2440,7 +2442,7 @@ void RadioLibWrapper::startRecv() {
// plus a worst-case packet airtime, plus margin for TCXO/transitions.
// Floored at 60s so a light-sleeping MCU (ESP32 wakes every ~30s) opens
// an observation window every couple of wakeups instead of on each one.
uint32_t rx_ms = _rx_ps_rx_us / 1000, sleep_ms = _rx_ps_sleep_us / 1000;
uint32_t rx_ms = _rx_ps_eff_rx_us / 1000, sleep_ms = _rx_ps_eff_sleep_us / 1000;
_wd_stuck_thresh = (rx_ms + sleep_ms) + 2 * (2 * rx_ms + sleep_ms)
+ getEstAirtimeFor(MAX_TRANS_UNIT) + 1000;
if (_wd_stuck_thresh < 60000) _wd_stuck_thresh = 60000;
@@ -2464,14 +2466,65 @@ void RadioLibWrapper::startRecv() {
}
int RadioLibWrapper::startReceiveMode() {
return _radio->startReceive();
return startContinuousReceive();
}
int RadioLibWrapper::startReceivePowerSaving() {
if (_nf_calib_active && _rx_ps_armed && isPacketPendingOrReceiving()) {
return RADIOLIB_ERR_NONE;
}
if (_rx_ps_armed) stopReceiveDutyCycle();
if (!_rx_ps_enabled || _nf_calib_active) {
if (!_rx_ps_enabled) _rx_ps_continuous_fallback = false;
const int16_t err = startContinuousReceive();
if (err != RADIOLIB_ERR_NONE || !_rx_ps_enabled) _rx_ps_last_error = err;
return err;
}
const bool planned_continuous = rxPowerSavingUsesContinuousFallback(_rx_ps_rx_us, _rx_ps_sleep_us);
const bool invalid_timing = !planned_continuous
&& !canStartRxPowerSavingDutyCycle(_rx_ps_rx_us, _rx_ps_sleep_us, rxPowerSavingTransitionUs());
if (planned_continuous || invalid_timing || !_rx_ps_arm_retry.canAttempt()) {
_rx_ps_continuous_fallback = true;
if (invalid_timing) _rx_ps_last_error = RX_POWERSAVING_ERR_TIMING;
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 effective_rx_us = _rx_ps_rx_us, effective_sleep_us = _rx_ps_sleep_us;
const int16_t err = armDutyCycle(irq_flags, irq_mask, &effective_rx_us, &effective_sleep_us);
_rx_ps_last_error = err;
if (err == RADIOLIB_ERR_NONE) {
_rx_ps_armed = true;
_rx_ps_continuous_fallback = false;
_rx_ps_eff_rx_us = effective_rx_us;
_rx_ps_eff_sleep_us = effective_sleep_us;
_rx_ps_arm_retry.recordSuccess();
return err;
}
_rx_ps_armed = false;
_rx_ps_continuous_fallback = true;
_rx_ps_eff_rx_us = _rx_ps_eff_sleep_us = 0;
++_rx_ps_arm_failures;
_rx_ps_arm_retry.recordFailure();
MESH_DEBUG_PRINTLN("RadioLibWrapper: startReceiveDutyCycle(%d), continuous RX fallback", err);
stopDutyCycleHardware();
const int16_t fallback_error = startContinuousReceive();
if (fallback_error != RADIOLIB_ERR_NONE) _rx_ps_last_error = fallback_error;
return fallback_error;
}
void RadioLibWrapper::stopReceiveDutyCycle() {
// The duty-cycle sequencer only stops on RxDone or an explicit standby;
// issuing other mode commands while it runs leads to undefined behaviour.
_radio->standby();
const int16_t err = stopDutyCycleHardware();
_rx_ps_armed = false;
_rx_ps_eff_rx_us = _rx_ps_eff_sleep_us = 0;
if (err != RADIOLIB_ERR_NONE) _rx_ps_last_error = err;
}
bool RadioLibWrapper::isPacketReady() {
@@ -2547,12 +2600,17 @@ bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sl
if (_beacon_owned) return false;
#endif
if (_cw_active) return false;
if (enabled && (!isValidRxPowerSavingPeriod(rx_us) || !isValidRxPowerSavingPeriod(sleep_us))) {
return false;
}
// A rejected request must not become the policy restored after radio2 ends.
if (enabled && !supportsRxPowerSaving()) return false;
if (_profile_rxps_suspended) {
_profile_saved_rxps = enabled;
_rx_ps_rx_us = rx_us;
_rx_ps_sleep_us = sleep_us;
_rx_ps_arm_retry.reset();
_rx_ps_last_error = RADIOLIB_ERR_NONE;
return true;
}
@@ -2562,12 +2620,25 @@ bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sl
_rx_ps_enabled = enabled;
_rx_ps_rx_us = rx_us;
_rx_ps_sleep_us = sleep_us;
_rx_ps_arm_retry.reset();
_rx_ps_last_error = RADIOLIB_ERR_NONE;
_rx_ps_continuous_fallback = enabled
&& rxPowerSavingUsesContinuousFallback(rx_us, sleep_us);
endReconfigure(resume_rx);
return true;
}
RxPowerSavingStatus RadioLibWrapper::getRxPowerSavingStatus() const {
RxPowerSavingStatus status;
status.supported = supportsRxPowerSaving();
status.armed = _rx_ps_armed;
status.last_error = _rx_ps_last_error;
status.arm_failures = _rx_ps_arm_failures;
status.effective_rx_us = _rx_ps_armed ? _rx_ps_eff_rx_us : 0;
status.effective_sleep_us = _rx_ps_armed ? _rx_ps_eff_sleep_us : 0;
return status;
}
int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) {
#if MESH_RADIO_TRACE_ENABLED
auto& radio_trace = mesh::radiotrace::trace();
+29 -1
View File
@@ -1,5 +1,7 @@
#pragma once
#include <Arduino.h>
#include "NoiseFloorEstimator.h"
#include "../TelemetryHistoryConfig.h"
#include "../CarrierTools.h"
@@ -35,6 +37,8 @@ struct PacketMillis {
};
class RadioLibWrapper : public mesh::Radio {
public:
static constexpr bool SupportsRxPowerSaving = false;
protected:
// Mode is owned by this loop, not by the ISR or another wrapper.
uint8_t state = 0;
@@ -228,6 +232,12 @@ protected:
unsigned long _nf_last_calib; // millis of last completed/attempted window
unsigned long _nf_calib_deadline; // abort window if the batch can't complete
unsigned long _nf_sample_from; // no samples before this (RX entry settle)
// Append diagnostic state so existing radio hot fields retain their offsets.
uint32_t _rx_ps_eff_rx_us = 0;
uint32_t _rx_ps_eff_sleep_us = 0;
int16_t _rx_ps_last_error = RADIOLIB_ERR_NONE;
uint32_t _rx_ps_arm_failures = 0;
RxPowerSavingArmRetryState _rx_ps_arm_retry;
// The T096 SX1262 bench test found RSSI valid and stable by 7 ms after RX
// entry, including the 500 kHz/SF5 worst-case profile. Retain the
// conservative 20 ms path for the other radio families.
@@ -318,7 +328,15 @@ protected:
int16_t performChannelScanWithTimeout(unsigned long timeout_ms);
#endif
virtual int startReceiveMode();
int startReceivePowerSaving();
virtual void stopReceiveDutyCycle();
virtual int16_t armDutyCycle(RadioLibIrqFlags_t irq_flags, RadioLibIrqFlags_t irq_mask,
uint32_t* effective_rx_us, uint32_t* effective_sleep_us) {
(void)irq_flags; (void)irq_mask; (void)effective_rx_us; (void)effective_sleep_us;
return RADIOLIB_ERR_UNSUPPORTED;
}
virtual int16_t stopDutyCycleHardware() { return _radio->standby(); }
virtual int16_t startContinuousReceive() { return _radio->startReceive(); }
virtual bool isPacketReady();
// true while the chip cannot service SPI (RX duty-cycle sleep window, or
// briefly while processing a command); radios expose this via the BUSY pin
@@ -526,6 +544,13 @@ public:
void onSendFinished() override;
bool isInRecvMode() const override;
bool setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) override;
bool supportsRxPowerSaving() const override { return SupportsRxPowerSaving; }
RxPowerSavingStatus getRxPowerSavingStatus() const;
virtual uint32_t rxPowerSavingTransitionUs() const { return rxPowerSavingDefaultTransitionUs(); }
void getRxPowerSavingTimings(uint32_t& rx_us, uint32_t& sleep_us) const {
rx_us = _rx_ps_rx_us;
sleep_us = _rx_ps_sleep_us;
}
bool isRxPowerSavingContinuousFallback() const override {
return _rx_ps_enabled && _rx_ps_continuous_fallback;
}
@@ -639,7 +664,10 @@ public:
bool getReceiveRetryDelay(uint32_t now, uint32_t& delay_ms) const override;
bool getNoiseFloorWakeDelay(uint32_t now, uint32_t& delay_ms) const override;
bool getProfileIdleDelay(uint32_t& delay_ms) const override;
void resetStats() { n_recv = n_sent = n_recv_errors = 0; }
void resetStats() {
n_recv = n_sent = n_recv_errors = _rx_ps_arm_failures = 0;
_rx_ps_arm_retry.reset();
}
uint8_t getRadioState() const override;
unsigned long getLastRecvMillis() const override { return last_recv_millis; }
+1 -1
View File
@@ -64,7 +64,7 @@ struct MyMesh {
bool millisHasNowPassed(unsigned deadline) { return millis() > deadline; }
} the_mesh;
static ESP32Board board;
static constexpr unsigned POWERSAVING_FIRSTSLEEP_SECS = 120;
static constexpr unsigned POWERSAVING_FIRSTSLEEP_SECS = 300;
static constexpr size_t LOCAL_SERIAL_COMMAND_MAX = 159;
static char command[LOCAL_SERIAL_COMMAND_MAX + 2] = {};
static bool command_overflow = false;
+1
View File
@@ -94,6 +94,7 @@ struct RadioLibWrapper {
uint16_t _rx_retry_delay_ms = 0;
bool _rx_retry_pending = false;
uint32_t _rx_ps_rx_us = 50000, _rx_ps_sleep_us = 50000;
uint32_t _rx_ps_eff_rx_us = 50000, _rx_ps_eff_sleep_us = 50000;
uint32_t _wd_last_transition = 0, _wd_stuck_thresh = 0, _wd_observe_ms = 0;
uint32_t n_wd_soft = 0, n_wd_hard = 0;
unsigned long _nf_sample_from = 0;
+1
View File
@@ -60,6 +60,7 @@ COMMON_QUERY_EXCEPTIONS = {
'pwrmgt.source': 'Measured power source',
'pwrmgt.support': 'Hardware power-management capabilities',
'radio.rxps.config': 'Detailed view of set radio.rxps',
'radio.rxps.status': 'Live radio status for set radio.rxps',
'role': 'Build-selected firmware role',
'rxps.wd': 'Runtime RX power-saving recovery counters',
'wifi.pwd': 'Setter is in the observer or standalone-WiFi delegate',
+101
View File
@@ -20,6 +20,7 @@ HARNESS = r'''
#include <helpers/AtomicFileWriter.h>
#include <helpers/PrefsSaveRouting.h>
#include <helpers/GpsPowerPolicy.h>
#include <helpers/radiolib/RXPowerSaving.h>
#if defined(ESP32_PLATFORM)
#include <helpers/esp32/CpuClockPolicy.h>
#endif
@@ -28,6 +29,12 @@ HARNESS = r'''
#define MAX_LORA_TX_POWER 22
#include <helpers/radiolib/RadioPowerLimits.h>
struct NodePrefs { @FIELDS@ };
struct RadioPrefsAdapter {
NodePrefs* _parent;
bool dirty = false;
void markDirty() { dirty = true; }
@RXPS_READ@
};
void markDirectRetryPrefsValid(NodePrefs*) {}
bool isValidLoRaBandwidth(float bw) { return bw==62.5f || bw==125; }
namespace mesh {
@@ -92,6 +99,28 @@ struct Capture { std::vector<uint8_t> bytes;
size_t write(const uint8_t* p,size_t n) { bytes.insert(bytes.end(),p,p+n);return n; }
};
int main() {
NodePrefs stored{};
stored.freq = 911.150f;
stored.powersaving_enabled = 1;
stored.rx_powersaving_enabled = 1;
stored.rx_ps_rx_us = 1234;
stored.rx_ps_sleep_us = 5678;
stored.rx_ps_level = 6;
stored.rx_ps_preamble = 32;
RadioPrefsAdapter adapter{&stored};
auto snapshot = adapter.readRxPowerSavingConfig();
assert(snapshot.enabled == 1 && snapshot.rx_us == 1234);
assert(snapshot.sleep_us == 5678 && snapshot.level == 6 && snapshot.preamble == 32);
snapshot.enabled = 0;
snapshot.rx_us = 2345;
snapshot.sleep_us = 6789;
snapshot.level = 7;
snapshot.preamble = 16;
assert(stored.rx_ps_sleep_us == 5678 && !adapter.dirty);
assert(!adapter.dirty && stored.rx_powersaving_enabled == 1);
assert(stored.rx_ps_rx_us == 1234 && stored.rx_ps_sleep_us == 5678);
assert(stored.rx_ps_level == 6 && stored.rx_ps_preamble == 32);
assert(stored.freq == 911.150f && stored.powersaving_enabled == 1);
CommonCLI cli;
assert(cli.saveCommonPrefs());
auto original=cli.fs.files["/com_prefs"];
@@ -151,6 +180,75 @@ int main() {
class CommonRadioPersistenceTest(unittest.TestCase):
def test_snapshot_constructors_and_both_real_adapters_support_cpp11(self):
source = r'''
#include <helpers/radiolib/RXPowerSaving.h>
#include <cassert>
#include <cstddef>
#include <initializer_list>
#include <type_traits>
constexpr RxPowerSavingConfig defaults;
static_assert(defaults.enabled == 0, "Disabled default");
static_assert(defaults.rx_us == RX_POWERSAVING_DEFAULT_RX_US, "Default RX period");
static_assert(defaults.sleep_us == RX_POWERSAVING_DEFAULT_SLEEP_US, "Default sleep period");
static_assert(defaults.level == RX_POWERSAVING_BALANCED_LEVEL, "Default level");
static_assert(defaults.preamble == RX_POWERSAVING_PROFILE_PREAMBLE, "Default preamble");
constexpr RxPowerSavingConfig explicit_config(1, 1234, 5678, 7, 32);
static_assert(explicit_config.enabled == 1 && explicit_config.rx_us == 1234
&& explicit_config.sleep_us == 5678 && explicit_config.level == 7
&& explicit_config.preamble == 32, "Value constructor");
static_assert(std::is_standard_layout<RxPowerSavingConfig>::value, "Snapshot layout");
static_assert(std::is_trivially_copyable<RxPowerSavingConfig>::value, "Value snapshot");
struct Fields {
uint8_t rx_powersaving_enabled = 0;
uint32_t rx_ps_rx_us = 0, rx_ps_sleep_us = 0;
uint8_t rx_ps_level = 0, rx_ps_preamble = 0;
};
struct CommonAdapter { Fields* _parent; @COMMON_READ@ };
struct CompanionAdapter { Fields* _parent; @COMPANION_READ@ };
int main() {
for (uint8_t enabled : {0, 1}) {
Fields fields;
fields.rx_powersaving_enabled = enabled;
fields.rx_ps_rx_us = 30000000;
fields.rx_ps_sleep_us = 2345;
fields.rx_ps_level = 10;
fields.rx_ps_preamble = 128;
CommonAdapter common{&fields};
CompanionAdapter companion{&fields};
auto a = common.readRxPowerSavingConfig();
auto b = companion.readRxPowerSavingConfig();
for (const auto& snapshot : {a, b}) {
assert(snapshot.enabled == enabled && snapshot.rx_us == 30000000
&& snapshot.sleep_us == 2345 && snapshot.level == 10 && snapshot.preamble == 128);
}
a.sleep_us = b.rx_us = 0;
assert(fields.rx_ps_rx_us == 30000000 && fields.rx_ps_sleep_us == 2345);
}
}
'''
for marker, path in (
('COMMON_READ', 'src/helpers/CommonCLI.h'),
('COMPANION_READ', 'examples/companion_radio/NodePrefs.h'),
):
header = (ROOT / path).read_text(encoding='ascii')
adapter = extract_braced(header, 'RxPowerSavingConfig readRxPowerSavingConfig(')
source = source.replace('@' + marker + '@', adapter.replace(' override', ''))
sanitizers = [] if os.name == 'nt' else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie']
with tempfile.TemporaryDirectory(prefix='rxps-snapshot-cpp11-') as directory:
work = Path(directory)
cpp, binary = work / 'snapshot.cpp', work / 'snapshot'
cpp.write_text(source, encoding='ascii')
for standard in ('c++11', 'c++17'):
with self.subTest(standard=standard):
built = subprocess.run([os.environ.get('CXX', 'g++'), '-std=' + standard,
'-Wall', '-Wextra', '-Werror', *sanitizers, '-I' + str(ROOT / 'src'),
str(cpp), '-o', str(binary)], capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
def test_production_transactions(self):
source = (ROOT / 'src/helpers/CommonCLI.cpp').read_text(encoding='utf-8')
header = (ROOT / 'src/helpers/CommonCLI.h').read_text(encoding='utf-8')
@@ -168,6 +266,8 @@ class CommonRadioPersistenceTest(unittest.TestCase):
tail = extract_braced(source, 'if (file.available() >= (int)sizeof(_prefs->bridge_format))')
code = HARNESS.replace('@FIELDS@',fields).replace('@METHODS@',("\n#include <stddef.h>\ntemplate<typename Writer>\n" + extract_braced(source, "static void writeCommonRetryPrefs(") + "\n" + methods))
code = code.replace('@SERIALIZER@',serializer).replace('@TAIL@',tail)
adapter = extract_braced(header, 'RxPowerSavingConfig readRxPowerSavingConfig(').replace(' override', '')
code = code.replace('@RXPS_READ@', adapter)
sanitizer_flags = [] if os.name == 'nt' else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie']
@@ -183,6 +283,7 @@ class CommonRadioPersistenceTest(unittest.TestCase):
built = subprocess.run(['g++','-std=c++17','-DENABLE_OTA=1','-D'+platform+'=1',
*sanitizer_flags,
'-I'+str(work),'-I'+str(ROOT/'test/fixtures/radio_profiles/mocks'),
'-I'+str(ROOT/'test/mocks'),
'-I'+str(ROOT/'src'),'-I'+str(ROOT/'src/helpers'),
str(work/'test.cpp'),'-o',str(exe)],
capture_output=True,text=True,timeout=60)
+2 -1
View File
@@ -265,7 +265,8 @@ inline char* utoa(unsigned int value,char* output,int base){
'-I',str(ROOT/'test/mocks'),'-I',str(ROOT/'src'),
'-I',str(ROOT/'src/helpers'),'-I',str(ROOT), str(source),
*[str(ROOT/'src/helpers'/name) for name in ('ConfigSerializer.cpp',
'DynamicConfigSerializer.cpp','CommonRadioPrefs.cpp','TxtDataHelpers.cpp')],
'DynamicConfigSerializer.cpp','CommonRadioPrefs.cpp',
'radiolib/RXPowerSaving.cpp','radiolib/RXPowerSavingCLI.cpp','TxtDataHelpers.cpp')],
'-o',str(binary)],capture_output=True,text=True)
self.assertEqual(built.returncode,0,built.stdout+built.stderr)
ran = subprocess.run([str(binary)],capture_output=True,text=True)
@@ -65,6 +65,40 @@ TEST(CompanionNodePrefs, DevicePowerSavingIsIndependentFromRxps) {
EXPECT_EQ(1, prefs.powersaving_enabled);
}
TEST(CompanionNodePrefs, RxpsAdapterReadsAnIndependentSnapshot) {
CompanionNodePrefs prefs;
prefs.rx_powersaving_enabled = 1;
prefs.rx_ps_rx_us = 1234;
prefs.rx_ps_sleep_us = 5678;
prefs.rx_ps_level = 6;
prefs.rx_ps_preamble = 32;
const CompanionNodePrefs& stored = prefs;
auto config = stored.getRadioPrefs()->readRxPowerSavingConfig();
EXPECT_EQ(1, config.enabled);
EXPECT_EQ(1234U, config.rx_us);
EXPECT_EQ(5678U, config.sleep_us);
EXPECT_EQ(6, config.level);
EXPECT_EQ(32, config.preamble);
config.sleep_us = 9999;
EXPECT_EQ(5678U, prefs.rx_ps_sleep_us);
EXPECT_FALSE(prefs.isDirty());
}
TEST(CompanionNodePrefs, RxpsSnapshotPreservesDisabledManualConfiguration) {
CompanionNodePrefs prefs;
prefs.freq = 911.150f;
prefs.powersaving_enabled = 1;
const auto config = prefs.getRadioPrefs()->readRxPowerSavingConfig();
EXPECT_EQ(0, config.enabled);
EXPECT_EQ(0U, config.rx_us);
EXPECT_EQ(0U, config.sleep_us);
EXPECT_EQ(0, config.level);
EXPECT_EQ(0, config.preamble);
EXPECT_FLOAT_EQ(911.150f, prefs.freq);
EXPECT_EQ(1, prefs.powersaving_enabled);
EXPECT_FALSE(prefs.isDirty());
}
TEST(CompanionNodePrefs, WiFiStateIsIndependentFromPowerSaving) {
CompanionNodePrefs prefs = {};
prefs.wifi_enabled = 1;
+2 -1
View File
@@ -197,7 +197,8 @@ class CompanionPacketStreamTests(unittest.TestCase):
['NRF52_PLATFORM=1', 'MESH_USB_LOGGING_AVAILABLE=1']
+ (['MESH_DUAL_CDC_LOGGING=1'] if dual else []),
('ConfigSerializer.cpp', 'DynamicConfigSerializer.cpp',
'CommonRadioPrefs.cpp', 'TxtDataHelpers.cpp'))
'CommonRadioPrefs.cpp', 'radiolib/RXPowerSaving.cpp',
'radiolib/RXPowerSavingCLI.cpp', 'TxtDataHelpers.cpp'))
def test_actual_full_dual_compiled_primary_transport(self):
fixture = ROOT / 'test/fixtures/nrf52_usb_console'
@@ -422,6 +422,8 @@ inline char* utoa(unsigned int value,char* output,int base){
str(ROOT / 'src/helpers/ConfigSerializer.cpp'),
str(ROOT / 'src/helpers/DynamicConfigSerializer.cpp'),
str(ROOT / 'src/helpers/CommonRadioPrefs.cpp'),
str(ROOT / 'src/helpers/radiolib/RXPowerSaving.cpp'),
str(ROOT / 'src/helpers/radiolib/RXPowerSavingCLI.cpp'),
str(ROOT / 'src/helpers/TxtDataHelpers.cpp'),
'-o', str(binary)], capture_output=True, text=True)
self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
+2 -1
View File
@@ -654,7 +654,8 @@ inline char* utoa(unsigned int value,char* output,int base){
'-I', str(ROOT / 'src/helpers'), '-I', str(ROOT), str(cpp),
*[str(ROOT / 'src/helpers' / name) for name in (
'ConfigSerializer.cpp', 'DynamicConfigSerializer.cpp',
'CommonRadioPrefs.cpp', 'TxtDataHelpers.cpp')],
'CommonRadioPrefs.cpp', 'radiolib/RXPowerSaving.cpp',
'radiolib/RXPowerSavingCLI.cpp', 'TxtDataHelpers.cpp')],
'-o', str(binary)], capture_output=True, text=True)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
ran = subprocess.run([str(binary)], capture_output=True, text=True)
+2 -1
View File
@@ -187,7 +187,8 @@ inline char* utoa(unsigned int value,char* output,int base){
'-I', str(ROOT / 'src/helpers'), '-I', str(ROOT), str(cpp),
*[str(ROOT / 'src/helpers' / name) for name in (
'ConfigSerializer.cpp', 'DynamicConfigSerializer.cpp',
'CommonRadioPrefs.cpp', 'TxtDataHelpers.cpp')],
'CommonRadioPrefs.cpp', 'radiolib/RXPowerSaving.cpp',
'radiolib/RXPowerSavingCLI.cpp', 'TxtDataHelpers.cpp')],
'-o', str(binary)], capture_output=True, text=True)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
ran = subprocess.run([str(binary)], capture_output=True, text=True)
+2 -1
View File
@@ -318,7 +318,8 @@ inline char* utoa(unsigned int value,char* output,int base){
'-I', str(ROOT / 'src/helpers'), '-I', str(ROOT), str(cpp),
*[str(ROOT / 'src/helpers' / name) for name in (
'ConfigSerializer.cpp', 'DynamicConfigSerializer.cpp',
'CommonRadioPrefs.cpp', 'TxtDataHelpers.cpp')],
'CommonRadioPrefs.cpp', 'radiolib/RXPowerSaving.cpp',
'radiolib/RXPowerSavingCLI.cpp', 'TxtDataHelpers.cpp')],
'-o', str(binary)], capture_output=True, text=True)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
self.assertNotIn('offsetof', built.stderr)
@@ -332,7 +332,8 @@ public:
'-I',str(ROOT/'src/helpers'),'-I',str(ROOT),str(work/'test.cpp'),
*[str(ROOT/'src/helpers'/name) for name in (
'ConfigSerializer.cpp','DynamicConfigSerializer.cpp',
'CommonRadioPrefs.cpp','TxtDataHelpers.cpp','RadioProfileCLI.cpp')],
'CommonRadioPrefs.cpp','radiolib/RXPowerSaving.cpp',
'radiolib/RXPowerSavingCLI.cpp','TxtDataHelpers.cpp','RadioProfileCLI.cpp')],
'-o',str(binary)],capture_output=True,text=True)
self.assertEqual(build.returncode,0,build.stdout+build.stderr)
run = subprocess.run([str(binary)],capture_output=True,text=True)
+2
View File
@@ -101,6 +101,8 @@ namespace mesh { struct Utils {
str(ROOT / "src/helpers/ConfigSerializer.cpp"),
str(ROOT / "src/helpers/DynamicConfigSerializer.cpp"),
str(ROOT / "src/helpers/CommonRadioPrefs.cpp"),
str(ROOT / "src/helpers/radiolib/RXPowerSaving.cpp"),
str(ROOT / "src/helpers/radiolib/RXPowerSavingCLI.cpp"),
str(ROOT / "src/helpers/TxtDataHelpers.cpp"),
"-o", str(binary),
], capture_output=True, text=True)
+175
View File
@@ -3,6 +3,7 @@
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
@@ -14,6 +15,180 @@ ROOT = Path(__file__).resolve().parents[1]
class CompanionRxpsGetterTests(unittest.TestCase):
def test_actual_status_formatter_and_prefixed_route_are_bounded(self):
shared = (ROOT / "src/helpers/CommonRadioPrefs.cpp").read_text(encoding="ascii")
common = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="ascii")
formatter = extract_braced(shared, "void CommonRadioPrefs::formatRxPowerSavingStatus(")
branch = extract_braced(common, 'if (strcmp(config, "radio.rxps.status") == 0)')
source = r'''
#include <helpers/radiolib/RXPowerSavingCLI.h>
#include <algorithm>
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
struct Control : RxPowerSavingControl {
RxPowerSavingStatus status;
RxPowerSavingStatus getRxPowerSavingStatus() const override { return status; }
} control;
#define WRAPPER_CLASS NativeRadio
struct NativeRadio {
struct Profiles { struct Params { uint8_t sf = 7; float bw = 125; } primary; } profile;
const Profiles* profiles() const { return &profile; }
void getRxPowerSavingTimings(uint32_t& rx_us, uint32_t& sleep_us) const {
rx_us = sleep_us = 30000000;
}
uint16_t currentPreambleLength() const { return 128; }
RxPowerSavingStatus getRxPowerSavingStatus() const { return control.status; }
} radio_driver;
struct CommonRadioPrefs {
RxPowerSavingConfig desired;
uint8_t getSpreadFactor() const { return 7; }
float getBandwidth() const { return 125; }
RxPowerSavingConfig readRxPowerSavingConfig() const { return desired; }
void formatRxPowerSavingStatus(char*, size_t) const;
} radio;
@FORMATTER@
struct Prefs { CommonRadioPrefs* getRadioPrefs() { return &radio; } } prefs;
struct CommonCLI {
Prefs* _prefs = &prefs;
void get(const char* config, char* reply) { @STATUS_BRANCH@ }
};
int main() {
radio.desired.enabled = 1;
radio.desired.rx_us = radio.desired.sleep_us = 30000000;
radio.desired.preamble = 128;
control.status.supported = true;
control.status.armed = true;
control.status.last_error = INT16_MIN;
control.status.arm_failures = UINT32_MAX;
control.status.effective_rx_us = UINT32_MAX;
control.status.effective_sleep_us = UINT32_MAX;
bool saw_truncation = false;
for (bool supported : {false, true}) {
control.status.supported = supported;
for (int16_t error : {int16_t(INT16_MIN), int16_t(0)}) {
control.status.last_error = error;
for (uint8_t level : {0, 6, 9, 10}) {
radio.desired.level = level;
const RxPowerSavingConfig before = radio.desired;
char full[512];
radio.formatRxPowerSavingStatus(full, sizeof(full));
const std::string expected = full;
assert(expected.find(error ? "err=-32768,fail=4294967295" : "err=0,fail=4294967295")
!= std::string::npos);
if (level == 9) assert(expected.find("level=9(!)") != std::string::npos);
if (level == 10) assert(expected.find("level=10(!)") != std::string::npos);
saw_truncation = saw_truncation || expected.size() > 156;
for (size_t capacity = 0; capacity <= 180; ++capacity) {
std::vector<unsigned char> bytes(capacity + 16, 0xA7);
char* reply = reinterpret_cast<char*>(bytes.data() + 8);
radio.formatRxPowerSavingStatus(reply, capacity);
if (capacity) {
const size_t count = std::min(expected.size(), capacity - 1);
assert(strlen(reply) == count && !memcmp(reply, expected.data(), count));
}
for (size_t n = 0; n < 8; ++n) assert(bytes[n] == 0xA7);
for (size_t n = 8 + capacity; n < bytes.size(); ++n) assert(bytes[n] == 0xA7);
}
// Infrastructure replies already reserve three bytes of their 160-byte
// buffer. Execute the production route with those bytes in place.
std::vector<unsigned char> bytes(176, 0xA7);
char* reply = reinterpret_cast<char*>(bytes.data() + 8);
memcpy(reply, " >", 3);
CommonCLI cli;
cli.get("radio.rxps.status", reply + 3);
assert(!memcmp(reply, " >", 3));
assert(strlen(reply + 3) == std::min(expected.size(), size_t(156)));
for (size_t n = 0; n < 8; ++n) assert(bytes[n] == 0xA7);
for (size_t n = 168; n < bytes.size(); ++n) assert(bytes[n] == 0xA7);
assert(!memcmp(&radio.desired, &before, sizeof(before)));
}
}
}
assert(saw_truncation && "The 157-byte prefixed route must exercise real truncation");
radio.formatRxPowerSavingStatus(nullptr, 160);
}
'''.replace("@FORMATTER@", formatter).replace("@STATUS_BRANCH@", branch)
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "RXPS formatter needs a host C++17 compiler")
sanitizers = [] if os.name == "nt" else [
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
with tempfile.TemporaryDirectory(prefix="meshcore-rxps-status-bounds-") as directory:
work = Path(directory)
cpp, binary = work / "bounds.cpp", work / "bounds"
cpp.write_text(source, encoding="ascii")
built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
*sanitizers, "-I", str(ROOT / "src"),
str(cpp), str(ROOT / "src/helpers/radiolib/RXPowerSaving.cpp"),
str(ROOT / "src/helpers/radiolib/RXPowerSavingCLI.cpp"),
"-o", str(binary)],
capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20)
self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
def test_status_alias_routes_to_shared_diagnostics_without_changing_legacy_getters(self):
companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text(encoding="ascii")
common = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="ascii")
whitelist = extract_braced(companion, "static bool isCompanionRadioPrefsCommand(")
branch = extract_braced(common, 'if (strcmp(config, "radio.rxps.status") == 0)')
source = r'''
#include <cassert>
#include <cstdio>
#include <cstdint>
#include <cstring>
struct RadioPrefs {
unsigned calls = 0;
void formatRxPowerSavingStatus(char* reply, size_t capacity) {
assert(capacity == 157);
++calls;
snprintf(reply, capacity, "> desired=on,effective=continuous,supported=yes,err=-2,fail=3");
}
} radio;
struct Prefs { RadioPrefs* getRadioPrefs() { return &radio; } } prefs;
struct CommonCLI {
Prefs* _prefs = &prefs;
void get(const char* config, uint32_t sender_timestamp, char* reply) {
(void)sender_timestamp;
@STATUS_BRANCH@
else strcpy(reply, "unhandled");
}
};
@WHITELIST@
int main() {
assert(isCompanionRadioPrefsCommand("get radio.rxps.status"));
for (const char* other : {"get radio.rxps.statusx", "get radio.rxps.status extra",
"get radio.rxps.config", "get radio.rxps", "set radio.rxps on"}) {
assert(!isCompanionRadioPrefsCommand(other));
}
CommonCLI cli;
char reply[160];
cli.get("radio.rxps.status", 123, reply);
assert(radio.calls == 1);
assert(strstr(reply, "effective=continuous") && strstr(reply, "err=-2,fail=3"));
cli.get("radio.rxps.statusx", 123, reply);
assert(radio.calls == 1 && !strcmp(reply, "unhandled"));
}
'''.replace("@STATUS_BRANCH@", branch).replace("@WHITELIST@", whitelist)
source = "#include <initializer_list>\n" + source
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "RXPS routing needs a host C++17 compiler")
sanitizers = [] if os.name == "nt" else [
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
with tempfile.TemporaryDirectory(prefix="meshcore-rxps-status-routes-") as directory:
work = Path(directory)
cpp, binary = work / "routes.cpp", work / "routes"
cpp.write_text(source, encoding="ascii")
built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
*sanitizers, str(cpp), "-o", str(binary)],
capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20)
self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
def test_real_getters_are_bounded_readonly_and_match_active_primary(self):
source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text(encoding="ascii")
getter = extract_braced(source, "bool MyMesh::handleRxPowerSavingGetCommand(")
+2 -1
View File
@@ -546,7 +546,8 @@ inline char* utoa(unsigned int value,char* output,int base){
"-I", str(ROOT / "src/helpers"), "-I", str(ROOT), str(cpp),
*[str(ROOT / "src/helpers" / name) for name in (
"ConfigSerializer.cpp", "DynamicConfigSerializer.cpp",
"CommonRadioPrefs.cpp", "TxtDataHelpers.cpp")],
"CommonRadioPrefs.cpp", "radiolib/RXPowerSaving.cpp",
"radiolib/RXPowerSavingCLI.cpp", "TxtDataHelpers.cpp")],
"-o", str(binary)], capture_output=True, text=True, timeout=90)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30)
+2 -1
View File
@@ -373,7 +373,8 @@ inline char* utoa(unsigned int value,char* output,int base){
[flag + '=1' for flag in platform.split(',')] +
['MESH_USB_LOGGING_AVAILABLE=1', 'MESH_DEBUG=' + str(debug)],
('ConfigSerializer.cpp', 'DynamicConfigSerializer.cpp',
'CommonRadioPrefs.cpp', 'TxtDataHelpers.cpp'))
'CommonRadioPrefs.cpp', 'radiolib/RXPowerSaving.cpp',
'radiolib/RXPowerSavingCLI.cpp', 'TxtDataHelpers.cpp'))
def test_real_raw_emitter_master_gate_compact_and_other_transports(self):
source = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
+2 -1
View File
@@ -197,7 +197,8 @@ inline char* utoa(unsigned value,char* out,int base){
if persistence:
command += [str(ROOT / "src/helpers" / name) for name in (
"ConfigSerializer.cpp", "DynamicConfigSerializer.cpp",
"CommonRadioPrefs.cpp", "TxtDataHelpers.cpp")]
"CommonRadioPrefs.cpp", "radiolib/RXPowerSaving.cpp",
"radiolib/RXPowerSavingCLI.cpp", "TxtDataHelpers.cpp")]
built = subprocess.run(command + ["-o", str(binary)], capture_output=True, text=True)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
ran = subprocess.run([str(binary)], capture_output=True, text=True)
+62 -1
View File
@@ -141,6 +141,8 @@ struct MainBoard {
};
struct ESP32Board : MainBoard {
bool inhibit_sleep = false;
bool button_wake_hold_active = false;
uint32_t button_wake_at = 0;
uint32_t uart_quiet_restore_at = 0;
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
mesh::UsbHostSleepPolicy usb_host_sleep_policy;
@@ -233,6 +235,12 @@ int main() {
"held button was stranded before the UI could poll it");
button_pressed = false;
board.sleep(30);
require(sleep_calls == 2, "released button lost its awake grace");
mock_millis += 29999U;
board.sleep(30);
require(sleep_calls == 2, "button awake grace expired early");
++mock_millis;
board.sleep(30);
require(sleep_calls == 3, "released button permanently disabled sleep");
#else
board.irq = static_cast<uint32_t>(-1);
@@ -411,6 +419,8 @@ struct MainBoard {
};
struct ESP32Board : MainBoard {
bool inhibit_sleep = false;
bool button_wake_hold_active = false;
uint32_t button_wake_at = 0;
uint32_t uart_quiet_restore_at = 0;
uint32_t getIRQGpio() { return P_LORA_DIO_1; }
bool isExternalPowered() const { return false; }
@@ -510,10 +520,13 @@ int main() {
timing_radio.rx = true;
require(mesh::morseTimingActive(activeSensorMesh().getProfileRadio()), "RX ownership was not visible to UI");
board.sleep(30);
require(sleep_calls == 2, "released button failed to allow sleep again");
require(sleep_calls == 1, "released button lost its awake grace");
g_mock_millis += 15000;
ui.loop();
require(!display.isOn(), "button screen failed to expire normally");
g_mock_millis += 15000;
board.sleep(30);
require(sleep_calls == 2, "expired button awake grace failed to allow sleep again");
timing_radio.rx = false;
require(!mesh::morseTimingActive(the_mesh.getProfileRadio()), "Morse ownership failed to release");
} catch (const std::exception& error) {
@@ -1018,6 +1031,54 @@ int main() {
timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
def test_button_wake_grace_expires_across_millis_rollover(self):
methods = board_sleep_methods() + "\n" + "\n".join(board_method(signature) for signature in (
"bool isUsbDataConnected() override",
"bool isUsbHostConnected() override", "bool isUsbSleepHeld()"))
harness = HARNESS.split("int main()", 1)[0] + r'''
int main() {
try {
attachHost(false);
Serial.end();
for (uint32_t start : {0U, UINT32_MAX - 15000U}) {
ESP32Board board;
mock_millis = start;
const unsigned before = sleep_calls;
board.sleep(30);
require(sleep_calls == before + 1, "fresh board gained an awake hold");
press_during_sleep = true;
board.sleep(30);
require(sleep_calls == before + 2 && button_pressed, "button failed to wake CPU");
button_pressed = false;
mock_millis = start + 29999U;
board.sleep(30);
require(sleep_calls == before + 2, "button grace ended early across rollover");
++mock_millis;
board.sleep(30);
require(sleep_calls == before + 3, "button grace never expired");
mock_millis = start + 1U;
board.sleep(30);
require(sleep_calls == before + 4, "expired button hold revived across rollover");
}
} catch (const std::exception& error) {
std::cerr << error.what() << '\n'; return 1;
}
}
'''
with tempfile.TemporaryDirectory(prefix="meshcore-button-grace-") as directory:
cpp = Path(directory) / "grace.cpp"
binary = Path(directory) / "grace"
cpp.write_text(harness.replace("@METHODS@", methods), encoding="ascii")
result = subprocess.run([
os.environ.get("CXX", "c++"), "-std=c++17", "-Wall", "-Wextra",
"-DARDUINO_USB_CDC_ON_BOOT=1", "-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=0",
"-DMOMENTARY_BUTTON_WAKE_FROM_SLEEP=1", "-DPIN_USER_BTN=38",
"-DMESH_USB_LOGGING_AVAILABLE=0", "-I", str(ROOT / "src"),
str(cpp), "-o", str(binary)], capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stderr)
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
def test_g3_enables_gesture_polling_after_gpio_wake(self):
profile = (ROOT / "variants/station_g3_esp32/platformio.ini").read_text()
def section(name):
+2 -1
View File
@@ -47,7 +47,8 @@ def compile_run(program, platform, actual_node_prefs=False):
(directory / 'Utils.h').write_text('#include <cassert>\n' + utils)
helper_sources = [str(ROOT / 'src/helpers' / name) for name in (
'ConfigSerializer.cpp', 'DynamicConfigSerializer.cpp',
'CommonRadioPrefs.cpp', 'TxtDataHelpers.cpp')]
'CommonRadioPrefs.cpp', 'radiolib/RXPowerSaving.cpp',
'radiolib/RXPowerSavingCLI.cpp', 'TxtDataHelpers.cpp')]
cpp, exe = directory / 'test.cpp', directory / 'test'
cpp.write_text(program)
compiled = subprocess.run([
+1
View File
@@ -165,6 +165,7 @@ struct Lr1110DutyCycleProbe {
uint16_t preambleLengthLoRa=32;
uint32_t _activityAt=0;
bool _headerSeen=false;
bool _lfClockRestoreNeeded=false;
uint32_t rx_ticks=0,sleep_ticks=0;
unsigned standby_calls=0,clock_calls=0,stage_calls=0,command_calls=0;
int16_t standby(uint8_t mode) {assert(mode==0);++standby_calls;return 0;}
+1
View File
@@ -256,6 +256,7 @@ public:
f"-I{ROOT / 'test/mocks'}", f"-I{ROOT / 'src'}", f"-I{ROOT / 'src/helpers'}", f"-I{ROOT}",
str(work / "radio.cpp"), *[str(ROOT / "src/helpers" / name) for name in (
"ConfigSerializer.cpp", "DynamicConfigSerializer.cpp", "CommonRadioPrefs.cpp",
"radiolib/RXPowerSaving.cpp", "radiolib/RXPowerSavingCLI.cpp",
"TxtDataHelpers.cpp", "RadioProfileCLI.cpp")], "-o", str(binary)])
output = checked([str(binary)], timeout=20)
replies = [line.removeprefix("RADIO:") for line in output.splitlines() if line.startswith("RADIO:")]
+2 -2
View File
@@ -178,7 +178,7 @@ class OfficialWebConsoleTests(unittest.TestCase):
source = source.split("int main() {", 1)[0] + r'''
int main() {
attachHost(true); Serial.terminal_open = true;
prefs.powersaving_enabled = 1; mock_millis = 300000;
prefs.powersaving_enabled = 1; mock_millis = 300001;
const unsigned before = yield_ms;
the_mesh.dual_radio_active = true;
power_loop();
@@ -219,7 +219,7 @@ int main() {
source = firmware_harness().split("int main() {", 1)[0] + r'''
int main() {
attachHost(false); Serial.terminal_open = false;
prefs.powersaving_enabled = 1; mock_millis = 300000;
prefs.powersaving_enabled = 1; mock_millis = 300001;
const unsigned before = yield_ms;
radio_driver.tx = true;
require(mesh::morseTimingActive(&radio_driver), "Morse TX ownership was lost");
+1
View File
@@ -47,6 +47,7 @@ inline char* utoa(unsigned int v,char* p,int b){if(b!=10)abort();sprintf(p,"%u",
if serializers:
command += [str(ROOT / "src/helpers/" / name) for name in (
"ConfigSerializer.cpp", "DynamicConfigSerializer.cpp", "CommonRadioPrefs.cpp",
"radiolib/RXPowerSaving.cpp", "radiolib/RXPowerSavingCLI.cpp",
"TxtDataHelpers.cpp")]
build = subprocess.run(command + ["-o", str(binary)], capture_output=True, text=True, timeout=90)
if build.returncode:
+3
View File
@@ -140,6 +140,7 @@ struct RadioLibWrapper {
bool _loop_event_pending=false, _cw_active=false, _profile_repair_required=false, _profile_refresh_required=false;
bool _rx_ps_armed=false, _rx_hold_continuous=false, _rx_ps_enabled=false;
bool _rx_ps_continuous_fallback=false, _nf_calib_active=false;
uint32_t _rx_ps_eff_rx_us=0, _rx_ps_eff_sleep_us=0;
float _last_snr=0, _last_rssi=0;
unsigned n_recv=0,n_recv_errors=0,n_sent=0;
uint32_t _rx_retry_at=0;
@@ -223,7 +224,9 @@ class RadioInterruptRecoveryTests(unittest.TestCase):
#define RADIOLIB_ERR_NONE 0
#define RADIOLIB_ERR_INVALID_FREQUENCY -2
#define RADIOLIB_ERR_UNKNOWN -16
#define RADIOLIB_ERR_UNSUPPORTED -2
#define RADIOLIB_NC 0xffffffff
using RadioLibIrqFlags_t = uint32_t;
class PhysicalLayer {
public:
int setOutputPower(int8_t){return 0;}
+2
View File
@@ -63,6 +63,8 @@ struct RadioLibWrapper {
return profile == 1 ? _secondary_floor_estimator : _floor_estimator;
}
uint32_t _rx_ps_rx_us=50000, _rx_ps_sleep_us=50000;
RxPowerSavingArmRetryState _rx_ps_arm_retry;
int16_t _rx_ps_last_error = RADIOLIB_ERR_NONE;
uint8_t _active_profile=0, _cur_sf=7, _cur_cr=5;
uint32_t _profile_generation=1, _profile_visit_stamp=0, _profile_retry_at=0;
uint32_t _profile_scan_generation[2]={};
+9 -2
View File
@@ -24,6 +24,7 @@ HARNESS = r'''
#include <RadioProfiles.h>
#include <helpers/radiolib/RadioInterruptEvents.h>
#include <helpers/radiolib/NoiseFloorEstimator.h>
#include <helpers/radiolib/RXPowerSaving.h>
#include <helpers/TelemetryHistoryConfig.h>
#define STATE_IDLE 0
#define STATE_RX 1
@@ -91,6 +92,9 @@ struct RadioLibWrapper {
uint16_t _rx_retry_delay_ms = 0;
bool _rx_retry_pending = false;
uint32_t _rx_mode_checked_at = 0, _rx_ps_rx_us = 50000, _rx_ps_sleep_us = 50000;
uint32_t _rx_ps_eff_rx_us = 50000, _rx_ps_eff_sleep_us = 50000;
int16_t _rx_ps_last_error = RADIOLIB_ERR_NONE;
RxPowerSavingArmRetryState _rx_ps_arm_retry;
unsigned long _nf_last_calib = 0, _nf_calib_deadline = 0, _nf_sample_from = 0;
unsigned long _wd_last_transition = 0, _wd_stuck_thresh = 0, _wd_observe_ms = 0;
int16_t _threshold = 0, _noise_floor = -105;
@@ -129,6 +133,10 @@ struct RadioLibWrapper {
++arms;
if (_nf_calib_active) ++calibration_arms;
if (!arm_result) _rx_ps_armed = _rx_ps_enabled && !_rx_ps_continuous_fallback && !_nf_calib_active;
if (_rx_ps_armed) {
_rx_ps_eff_rx_us = _rx_ps_rx_us;
_rx_ps_eff_sleep_us = _rx_ps_sleep_us;
}
if (inject_arm_irq) events.record();
return arm_result;
}
@@ -701,8 +709,7 @@ def transition_harness(source=None):
('void', 'requestPeriodicNoiseFloorRefresh'), ('void', 'recalibrateNoiseFloor'),
('bool', 'resetAGCIfIdle'), ('bool', 'tryResetAGC'), ('void', 'resetAGC'),
('bool', 'getNoiseFloorWakeDelay')]
methods = method(rxps, 'inline bool rxPowerSavingUsesContinuousFallback(') + '\n'
methods += '\n'.join(method(source, f'{kind} RadioLibWrapper::{name}(')
methods = '\n'.join(method(source, f'{kind} RadioLibWrapper::{name}(')
for kind, name in names)
constants = '\n'.join(re.findall(r'^#define NF_\w+\s+[^\n]+', source, re.MULTILINE))
constants += '\n' + re.search(
+1 -2
View File
@@ -53,8 +53,7 @@ class RadioReceiveRetryTests(unittest.TestCase):
('void', 'onSendFinished'), ('void', 'begin'),
('bool', 'getNoiseFloorWakeDelay'),
('bool', 'getReceiveRetryDelay'), ('void', 'rxPsWatchdogCheck')]
methods = method(rxps, 'inline bool rxPowerSavingUsesContinuousFallback(') + '\n'
methods += '\n'.join(method(source, f'{kind} RadioLibWrapper::{name}(')
methods = '\n'.join(method(source, f'{kind} RadioLibWrapper::{name}(')
for kind, name in names)
constants = '\n'.join(re.findall(r'^#define NF_\w+\s+[^\n]+', source, re.MULTILINE))
constants += '\n' + re.search(
+17
View File
@@ -132,6 +132,23 @@ int main() {
class RadioRegulatorTests(unittest.TestCase):
def test_thinknode_m4_recipe_uses_lr1110_capture_cost_before_target_headers(self):
recipe = (ROOT / 'variants/thinknode_m4/platformio.ini').read_text()
family_flags = ['-DUSE_LR1110=1'] if ' -D USE_LR1110' in recipe else []
compile_run(self, r'''
#include <helpers/radiolib/RXPowerSaving.h>
#include <cassert>
int main() {
// CommonCLI includes its RXPS helpers before target.h can select a wrapper.
// The board recipe must therefore select the LR11x0 eight-symbol cost.
assert(rxPowerSavingDefaultCaptureCost() == 8.0f);
uint32_t family_rx=0, family_sleep=0, explicit_rx=0, explicit_sleep=0;
assert(calcRxPowerSavingLevel(9,8,62.5f,32,&family_rx,&family_sleep));
assert(calcRxPowerSavingLevel(9,8,62.5f,32,&explicit_rx,&explicit_sleep,8.0f));
assert(family_rx==explicit_rx && family_sleep==explicit_sleep);
}
''', flags=family_flags)
def exercise(self, chip, flags=(), dcdc=True):
source = (ROOT / f'src/helpers/radiolib/Custom{chip}.h').read_text()
if chip == 'LR2021':
+61 -3
View File
@@ -47,6 +47,7 @@ def build_source(family='CustomSX1262',remove_reset=False,include_duty=False):
#define RADIOLIB_LR11X0_STANDBY_RC 0
#define RADIOLIB_LR11X0_LF_CLK_RC 1
#define RADIOLIB_LR11X0_LF_BUSY_RELEASE_ENABLED 2
#define RADIOLIB_LR11X0_LF_BUSY_RELEASE_DISABLED 0
#define RADIOLIB_RADIO_MODE_RX 1
#define RADIOLIB_LR11X0_RX_DUTY_CYCLE_MODE_RX 0
#define RADIOLIB_ASSERT(state) do { if((state)!=0)return(state); } while(0)
@@ -74,10 +75,10 @@ struct Chip : LR2021 {
int16_t clearIrqState(uint32_t flags){irq &= ~flags;rx_status=0;gpio_levels[module.irq]=false;return clear_status;}
int16_t setPacketParamsLoRa(size_t,uint8_t,uint8_t,uint8_t,uint8_t){return packet_failure;}
int snapshot_failure=0,packet_failure=0;
bool _rx_ps_rf_rx_disabled=false;
bool _rx_ps_rf_rx_disabled=false,_lfClockRestoreNeeded=false;
uint8_t spreadingFactor=7;float bandwidthKhz=62.5;uint32_t tcxoDelay=0;
int lf_failure=0,stage_failure=0;
int16_t configLfClock(int){return lf_failure;}
int lf_failure=0,stage_failure=0;unsigned lf_calls=0;
int16_t configLfClock(int){++lf_calls;return lf_failure;}
int16_t stageMode(int,RadioModeConfig_t*){return stage_failure;}
int16_t setRxDutyCycle(uint32_t,uint32_t,int){return startDuty();}
size_t preambleLengthLoRa=32;uint8_t crcTypeLoRa=1,implicitLen=255,headerType=0,invertIQEnabled=0;
@@ -145,6 +146,13 @@ struct Chip : LR2021 {
'void RadioLibWrapper::serviceProfileScan('))
if family=='CustomLR1110':
source=source.replace('SX1262ProfileSwitchState _profileSwitch','LR1110ProfileSwitchState _profileSwitch')
clock_methods='\n'.join(method(chip,signature)for signature in (
'bool needsLfClockRestore()', 'int16_t restoreLfClock()'))
source=source.replace(' bool _rx_ps_rf_rx_disabled=',clock_methods+'\n bool _rx_ps_rf_rx_disabled=')
wrapper=(ROOT/'src/helpers/radiolib/CustomLR1110Wrapper.h').read_text()
continuous=method(wrapper,'int16_t startContinuousReceive()').replace(' override','')
continuous=continuous.replace('static_cast<CustomLR1110*>','static_cast<Chip*>')
source=source.replace(' int startReceiveMode(){return chip.startReceive();}',continuous+'\n int startReceiveMode(){return chip.startReceive();}')
if family.startswith('CustomLR'):
source+="\n#define ACTIVITY_PREAMBLE 16\n#define ACTIVITY_HEADER 32\n#define ACTIVITY_HEADER_ERROR 64\n"
else:
@@ -268,7 +276,57 @@ int main(){
}
'''
LR_CLOCK_CHECKS=r'''
static void fastContext(Chip& chip){
chip._profileSwitch.rxResult(0,true);
chip._profileSwitch.begin(true,true);chip._profileSwitch.standbyResult(0);
assert(chip._profileSwitch.canResumeFast());
}
int main(){
auto* wrapper=new CustomLR1110Wrapper;
auto& chip=wrapper->chip;
fastContext(chip);
const unsigned standbys=chip.standbys,clocks=chip.lf_calls;
assert(wrapper->startContinuousReceive()==0);
assert(chip.standbys==standbys&&"ordinary profile RX must not add a standby");
assert(chip.lf_calls==clocks&&chip._profileFastResumes==1);
assert(!chip.needsLfClockRestore());
// Entering duty cycle changes BUSY release policy. Failed restoration must
// stay pending, and must not claim that continuous RX has restarted.
assert(chip.startReceiveDutyCycle(100000,100000)==0);
assert(chip.needsLfClockRestore());
chip.lf_failure=-88;
const unsigned arms=chip.arms;
assert(wrapper->startContinuousReceive()==-88);
assert(chip.needsLfClockRestore()&&chip.arms==arms);
chip.lf_failure=0;
assert(wrapper->startContinuousReceive()==0);
assert(!chip.needsLfClockRestore());
fastContext(chip);
const unsigned next_standbys=chip.standbys,next_clocks=chip.lf_calls;
assert(wrapper->startContinuousReceive()==0);
assert(chip.standbys==next_standbys&&chip.lf_calls==next_clocks);
assert(chip._profileFastResumes==2);
delete wrapper;
}
'''
class RadioRxActivityEpochTests(unittest.TestCase):
def test_lr1110_clock_restore_keeps_ordinary_profile_rx_fast(self):
source=build_source('CustomLR1110',include_duty=True)
compile_run(source+LR_CLOCK_CHECKS,['-DESP32_PLATFORM=1','-DRADIO_CLASS=CustomLR1110'])
def test_lr1110_unconditional_clock_restore_breaks_profile_fast_path(self):
source=build_source('CustomLR1110',include_duty=True)
bypass=' if (!radio->needsLfClockRestore()) return _radio->startReceive();'
self.assertIn(bypass,source)
with self.assertRaises(AssertionError)as failure:
compile_run(source.replace(bypass,'')+LR_CLOCK_CHECKS,
['-DESP32_PLATFORM=1','-DRADIO_CLASS=CustomLR1110'])
self.assertIn('ordinary profile RX must not add a standby',str(failure.exception))
def test_actual_receive_rearm_preserves_new_preamble_at_due_scan(self):
for family in FAMILIES:
with self.subTest(family=family):
@@ -1,6 +1,7 @@
#include <gtest/gtest.h>
#include <cmath>
#include <helpers/radiolib/RXPowerSaving.h>
#include <helpers/radiolib/RXPowerSavingCLI.h>
#include <helpers/CLICommandUtils.h>
TEST(RxPowerSaving, WirePreamblesStayCompatibleRegardlessOfLocalPowerSaving) {
@@ -146,6 +147,151 @@ TEST(RxPowerSavingCLI, ParsesNewNamesAndRejectsTrailingJunk) {
}
}
namespace {
class RxPowerSavingTestControl : public RxPowerSavingControl {
public:
bool accept = true;
bool enabled = false;
bool config_error = false;
uint32_t transition_us = 1000;
float capture_cost = 8;
uint32_t rx_us = 0, sleep_us = 0;
RxPowerSavingStatus status;
RxPowerSavingStatus getRxPowerSavingStatus() const override { return status; }
bool setRxPowerSaving(bool on, uint32_t rx, uint32_t sleep) override {
if (!accept) return false;
enabled = on; rx_us = rx; sleep_us = sleep;
return true;
}
void setRxPowerSavingConfigError(bool invalid) override { config_error = invalid; }
float rxPowerSavingCaptureCostSymbols() const override { return capture_cost; }
uint32_t rxPowerSavingTransitionUs() const override { return transition_us; }
};
}
TEST(RxPowerSaving, RepeatedArmFailuresStopUntilResetOrSuccess) {
RxPowerSavingArmRetryState retry;
for (unsigned i = 0; i < 3; ++i) {
EXPECT_TRUE(retry.canAttempt());
retry.recordFailure();
}
EXPECT_FALSE(retry.canAttempt());
retry.recordFailure();
EXPECT_EQ(3, retry.consecutiveFailures());
retry.reset();
EXPECT_TRUE(retry.canAttempt());
retry.recordFailure();
retry.recordSuccess();
EXPECT_EQ(0, retry.consecutiveFailures());
}
TEST(RxPowerSaving, ConfigUsesRuntimeRadioTimingAndKeepsWirePreamble) {
RxPowerSavingTestControl control;
RxPowerSavingConfig config;
config.enabled = 1;
ASSERT_TRUE(applyRxPowerSavingConfig(&config, 6, 500, &control));
EXPECT_TRUE(control.enabled);
EXPECT_FALSE(control.config_error);
EXPECT_FALSE(rxPowerSavingUsesContinuousFallback(control.rx_us, control.sleep_us));
EXPECT_EQ(16, config.preamble);
uint32_t rx = 0, sleep = 0;
ASSERT_TRUE(recalcRxPowerSavingFromLevel(config.level, 6, 500, 16, &rx, &sleep,
nullptr, nullptr, control.transition_us, control.capture_cost));
EXPECT_EQ(rx, control.rx_us);
EXPECT_EQ(sleep, control.sleep_us);
}
TEST(RxPowerSaving, InvalidConfigFallsBackWithoutOverwritingRequestedPolicy) {
RxPowerSavingTestControl control;
RxPowerSavingConfig config;
config.enabled = 1;
config.level = 0;
config.rx_us = 500;
EXPECT_FALSE(applyRxPowerSavingConfig(&config, 8, 62.5f, &control));
EXPECT_EQ(1, config.enabled);
EXPECT_EQ(500U, config.rx_us);
EXPECT_FALSE(control.enabled);
EXPECT_TRUE(control.config_error);
EXPECT_FALSE(normalizeRxPowerSavingConfig(&config, 8, NAN));
}
TEST(RxPowerSavingCLI, RejectedCommandsNeverOverwriteSavedConfig) {
RxPowerSavingTestControl control;
RxPowerSavingConfig config;
char reply[160];
for (const char* invalid : {"max extra", "level 4294967296", "level 256", "500 1000",
"max preamble 65552", "riskyworkingmax", "level 8 preamble 24"}) {
EXPECT_FALSE(RXPowerSavingCLI::set(invalid, 8, 62.5f, &config, &control, reply, sizeof(reply))) << invalid;
EXPECT_EQ(0, config.enabled);
EXPECT_EQ(RX_POWERSAVING_BALANCED_LEVEL, config.level);
}
control.accept = false;
EXPECT_FALSE(RXPowerSavingCLI::set("max", 8, 62.5f, &config, &control, reply, sizeof(reply)));
EXPECT_EQ(0, config.enabled);
}
TEST(RxPowerSavingCLI, OffPreservesRequestedLevelAndManualPeriods) {
RxPowerSavingTestControl control;
RxPowerSavingConfig config;
char reply[160];
ASSERT_TRUE(RXPowerSavingCLI::set("max preamble 64", 5, 500, &config, &control, reply, sizeof(reply)));
EXPECT_EQ(8, config.level);
EXPECT_EQ(64, config.preamble);
ASSERT_TRUE(RXPowerSavingCLI::set("off", 5, 500, &config, &control, reply, sizeof(reply)));
EXPECT_EQ(0, config.enabled);
EXPECT_EQ(8, config.level);
ASSERT_TRUE(RXPowerSavingCLI::set("12000 5000", 8, 62.5f, &config, &control, reply, sizeof(reply)));
EXPECT_EQ(0, config.level);
EXPECT_EQ(12000U, config.rx_us);
EXPECT_EQ(5000U, config.sleep_us);
}
TEST(RxPowerSavingCLI, StatusUsesLiveWirePreambleAndReportsHardwareClamp) {
RxPowerSavingTestControl control;
control.status.supported = control.status.armed = true;
control.status.effective_rx_us = 78000;
control.status.effective_sleep_us = 60000;
RxPowerSavingConfig config;
config.enabled = 1;
char reply[160];
RXPowerSavingCLI::get(&config, &control, 8, 62.5f, reply, sizeof(reply), 32);
EXPECT_NE(nullptr, strstr(reply, "effective=armed"));
EXPECT_NE(nullptr, strstr(reply, "catch=17.4"));
EXPECT_NE(nullptr, strstr(reply, "erx=78000,eslp=60000"));
control.status.armed = false;
RXPowerSavingCLI::get(&config, &control, 8, 62.5f, reply, sizeof(reply), 32);
EXPECT_EQ(nullptr, strstr(reply, "catch="));
}
TEST(RxPowerSavingCLI, StatusCatchRejectsInvalidBandwidthAndBoundsMaximumPeriods) {
RxPowerSavingTestControl control;
control.status.supported = control.status.armed = true;
RxPowerSavingConfig config;
config.sleep_us = config.rx_us = RX_POWERSAVING_MAX_PERIOD_US;
control.status.effective_rx_us = config.rx_us;
control.status.effective_sleep_us = config.sleep_us;
char reply[160];
for (float bw : {NAN, INFINITY, 0.0f, -1.0f, 501.0f}) {
RXPowerSavingCLI::get(&config, &control, 5, bw, reply, sizeof(reply), 65535);
EXPECT_EQ(nullptr, strstr(reply, "catch="));
}
RXPowerSavingCLI::get(&config, &control, 5, 500.0f, reply, sizeof(reply), 65535);
EXPECT_NE(nullptr, strstr(reply, "catch=-403215.0"));
EXPECT_LT(strlen(reply), sizeof(reply));
}
TEST(RxPowerSavingCLI, CompactStatusKeepsExperimentalWarningAndFullSetterNames) {
RxPowerSavingTestControl control;
RxPowerSavingConfig config;
char reply[160];
for (const char* name : {"overdrive", "riskyWorkingMax"}) {
ASSERT_TRUE(RXPowerSavingCLI::set(name, 8, 62.5f, &config, &control, reply, sizeof(reply)));
EXPECT_NE(nullptr, strstr(reply, name));
RXPowerSavingCLI::get(&config, &control, 8, 62.5f, reply, sizeof(reply));
EXPECT_NE(nullptr, strstr(reply, config.level == 9 ? "level=9(!)" : "level=10(!)"));
}
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
+141
View File
@@ -0,0 +1,141 @@
"""Keep unsupported hardware out of RXPS math without changing CLI readback."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from cpp_source import body
ROOT = Path(__file__).resolve().parents[1]
RADIO_STUB = r'''
#pragma once
#include <cstdint>
#define RADIOLIB_ERR_NONE 0
#define RADIOLIB_ERR_INVALID_FREQUENCY -2
#define RADIOLIB_ERR_UNKNOWN -16
#define RADIOLIB_ERR_UNSUPPORTED -2
#define RADIOLIB_NC 0xffffffff
using RadioLibIrqFlags_t = uint32_t;
class PhysicalLayer {
public:
int setOutputPower(int8_t){return 0;}
int setFrequency(float){return 0;}
int standby(){return 0;}
int startReceive(){return 0;}
int transmitDirect(){return 0;}
int setPreambleLength(uint16_t){return 0;}
uint8_t randomByte(){return 0;}
};
'''
HARNESS = r'''
long random(long,long);
#include <helpers/radiolib/RadioLibWrappers.h>
#include <helpers/CLICommandUtils.h>
#include <cstdio>
#include <cstring>
#define WRAPPER_CLASS RadioLibWrapper
static_assert(!RadioLibWrapper::SupportsRxPowerSaving, "actual unsupported wrapper capability");
struct NodePrefs {
uint8_t rx_powersaving_enabled=0,rx_ps_level=5,rx_ps_preamble=16,sf=8;
uint32_t rx_ps_rx_us=12345,rx_ps_sleep_us=67890;
float bw=62.5f;
};
struct Callbacks {
unsigned calls=0;
bool setRxPowerSaving(bool on,uint32_t,uint32_t){++calls;return !on;}
};
struct CommonCLI {
NodePrefs* _prefs; Callbacks* _callbacks; unsigned saves=0;
CommonCLI(NodePrefs& p,Callbacks& c):_prefs(&p),_callbacks(&c){}
static bool calculateRxPowerSavingLevel(uint32_t,uint8_t,float,uint32_t,uint32_t*,uint32_t*);
static bool recalculateRxPowerSavingFromLevel(NodePrefs*);
void savePrefs(){++saves;}
void set(const char* config,char* reply){@SETTER@}
void getConfig(const char* config,char* reply){@GETTER@}
};
@CALCULATOR@
@RECALCULATOR@
@ADVICE@
#define CHECK(value,message) do { if(!(value)){std::fprintf(stderr,"%s\n",message);return 1;} } while(0)
int main(){
NodePrefs prefs; Callbacks callbacks; CommonCLI cli(prefs,callbacks); char reply[160]={};
uint32_t rx=1,sleep=2;
CHECK(!cli.calculateRxPowerSavingLevel(8,8,62.5f,16,&rx,&sleep),"unsupported model must not run");
CHECK(rx==1&&sleep==2,"unsupported model must preserve caller data");
CHECK(!cli.recalculateRxPowerSavingFromLevel(&prefs),"unsupported retune must not run");
CHECK(prefs.rx_ps_rx_us==12345&&prefs.rx_ps_sleep_us==67890,"manual periods must survive unsupported retune");
std::strcpy(reply,"OK");prefs.rx_powersaving_enabled=1;
appendRxPowerSavingAdjustmentNote(reply,&prefs,5,500);
CHECK(!std::strcmp(reply,"OK"),"unsupported hardware must not advise timing adjustments");
prefs.rx_powersaving_enabled=0;
cli.set("radio.rxps max",reply);
CHECK(!std::strcmp(reply,"ERROR: RX powersaving unsupported"),"enable must report the actual unsupported error");
CHECK(!callbacks.calls&&!cli.saves,"unsupported enable must not touch hardware or saved state");
cli.set("radio.rxps off",reply);
CHECK(callbacks.calls==1&&cli.saves==1,"off must remain accepted");
CHECK(prefs.rx_ps_level==5&&prefs.rx_ps_preamble==16,"off must preserve configured level and preamble");
cli.getConfig("radio.rxps.config",reply);
CHECK(!std::strcmp(reply,"> off,level=5,preamble=16,rx=12345,sleep=67890"),"legacy config readback must remain exact");
CHECK(rxPowerSavingPreambleForParams(7,500)==32,"wire preamble must stay unchanged");
CHECK(rxPowerSavingPreambleForParams(6,500)==64,"fast wire preamble must stay unchanged");
CHECK(rxPowerSavingPreambleForParams(5,500)==128,"fastest wire preamble must stay unchanged");
}
'''
class RxpsHardwareCapabilityTests(unittest.TestCase):
def probe(self, standard, mutate=False):
common = (ROOT / 'src/helpers/CommonCLI.cpp').read_text()
setter = body(common, 'if (memcmp(config, "radio.rxps ", 11) == 0)')
if mutate:
guard = body(setter, 'if (!WRAPPER_CLASS::SupportsRxPowerSaving)')
setter = setter.replace(guard, '', 1)
code = HARNESS.replace('@SETTER@', setter)
code = code.replace('@GETTER@', body(common, 'if (strcmp(config, "radio.rxps.config") == 0)'))
for marker, signature in (
('@CALCULATOR@', 'bool CommonCLI::calculateRxPowerSavingLevel('),
('@RECALCULATOR@', 'bool CommonCLI::recalculateRxPowerSavingFromLevel('),
('@ADVICE@', 'static void appendRxPowerSavingAdjustmentNote(')):
code = code.replace(marker, body(common, signature))
# The setter references the following definition, just as production does.
code = code.replace('struct CommonCLI {',
'static void appendRxPowerSavingAdjustmentNote(char*,const NodePrefs*,uint8_t,float);\nstruct CommonCLI {')
with tempfile.TemporaryDirectory(prefix='rxps-hardware-capability-') as folder:
work = Path(folder)
(work / 'RadioLib.h').write_text(RADIO_STUB)
cpp, binary = work / 'test.cpp', work / 'test'
cpp.write_text(code)
built = subprocess.run([os.environ.get('CXX', 'g++'), '-std=' + standard,
'-pedantic-errors', '-Os', '-ffunction-sections', '-fdata-sections',
'-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie',
'-DLORA_SF=7', '-DLORA_BW=125', '-I' + str(work),
# Existing SDK headers contain inline variables accepted by
# the firmware toolchain. Keep strict diagnostics on the
# extracted production CLI bodies, outside those headers.
'-isystem', str(ROOT / 'src'), '-isystem', str(ROOT / 'test/mocks'),
str(cpp), '-Wl,--gc-sections', '-o', str(binary)], capture_output=True, text=True)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
result = subprocess.run([str(binary)], capture_output=True, text=True)
if mutate:
self.assertNotEqual(result.returncode, 0)
self.assertIn('enable must report the actual unsupported error', result.stderr)
else:
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
symbols = subprocess.run(['nm', '-C', str(binary)], capture_output=True, text=True, check=True).stdout
self.assertNotIn(' calcRxPowerSavingLevelAtOrAbove(', symbols)
self.assertNotIn(' recalcRxPowerSavingFromLevel(', symbols)
def test_actual_base_capability_skips_model_and_preserves_commands(self):
for standard in ('c++11', 'c++17'):
with self.subTest(standard=standard):
self.probe(standard)
def test_missing_early_unsupported_error_is_detected(self):
self.probe('c++11', mutate=True)
if __name__ == '__main__':
unittest.main()
+259
View File
@@ -0,0 +1,259 @@
"""Execute production RXPS arming, fallback and status against faultable hardware."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from cpp_source import body
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <string>
#include <helpers/radiolib/RXPowerSaving.h>
#define MESH_ENABLE_TX_BEACONS 1
#define MESH_DEBUG_PRINTLN(...) ((void)0)
constexpr int RADIOLIB_ERR_NONE = 0;
using RadioLibIrqFlags_t = uint32_t;
constexpr uint32_t RADIOLIB_IRQ_RX_DEFAULT_FLAGS = 0x100;
constexpr unsigned RADIOLIB_IRQ_PREAMBLE_DETECTED = 1, RADIOLIB_IRQ_RX_DONE = 2;
constexpr unsigned RADIOLIB_IRQ_TIMEOUT = 3, RADIOLIB_IRQ_CRC_ERR = 4;
constexpr unsigned RADIOLIB_IRQ_HEADER_ERR = 5;
struct RadioLibWrapper {
bool _rx_ps_enabled = true, _rx_ps_armed = false;
bool _rx_ps_continuous_fallback = false, _nf_calib_active = false;
bool _profile_rxps_suspended = false;
bool _profile_saved_rxps = false, _beacon_owned = false, _cw_active = false;
uint32_t _rx_ps_rx_us = 50000, _rx_ps_sleep_us = 50000;
uint32_t _rx_ps_eff_rx_us = 0, _rx_ps_eff_sleep_us = 0;
int16_t _rx_ps_last_error = RADIOLIB_ERR_NONE;
uint32_t _rx_ps_arm_failures = 0, n_recv = 0, n_sent = 0, n_recv_errors = 0;
RxPowerSavingArmRetryState _rx_ps_arm_retry;
bool packet_pending = false, supported = true;
int16_t arm_error = 0, continuous_error = 0, stop_error = 0;
uint32_t hardware_rx_us = 48000, hardware_sleep_us = 47000;
unsigned arm_calls = 0, continuous_calls = 0, stop_calls = 0;
unsigned begin_calls = 0, end_calls = 0;
uint8_t reconfigure_result = 1;
std::string events;
bool supportsRxPowerSaving() const { return supported; }
bool isPacketPendingOrReceiving() const { return packet_pending; }
uint32_t rxPowerSavingTransitionUs() const { return 2600; }
int16_t startContinuousReceive() {
++continuous_calls; events += 'C'; return continuous_error;
}
int16_t stopDutyCycleHardware() {
++stop_calls; events += 'S'; return stop_error;
}
int16_t armDutyCycle(RadioLibIrqFlags_t flags, RadioLibIrqFlags_t mask,
uint32_t* rx_us, uint32_t* sleep_us) {
++arm_calls; events += 'A';
assert(flags == (RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED)));
assert(mask == ((1UL << RADIOLIB_IRQ_RX_DONE) | (1UL << RADIOLIB_IRQ_TIMEOUT)
| (1UL << RADIOLIB_IRQ_CRC_ERR) | (1UL << RADIOLIB_IRQ_HEADER_ERR)));
assert(*rx_us == _rx_ps_rx_us && *sleep_us == _rx_ps_sleep_us);
if (!arm_error) { *rx_us = hardware_rx_us; *sleep_us = hardware_sleep_us; }
return arm_error;
}
uint8_t beginReconfigure() { ++begin_calls; return reconfigure_result; }
void endReconfigure(bool resume_rx) {
++end_calls;
if (resume_rx) assert(startReceivePowerSaving() == RADIOLIB_ERR_NONE);
}
int startReceivePowerSaving();
void stopReceiveDutyCycle();
bool setRxPowerSaving(bool, uint32_t, uint32_t);
RxPowerSavingStatus getRxPowerSavingStatus() const;
@RESET_STATS@
};
@METHODS@
static void exhaust(RadioLibWrapper& radio) {
radio.arm_error = -708;
for (unsigned attempt = 1; attempt <= 3; ++attempt) {
assert(radio.startReceivePowerSaving() == RADIOLIB_ERR_NONE);
assert(radio.arm_calls == attempt && radio.stop_calls == attempt);
assert(radio.continuous_calls == attempt);
const auto status = radio.getRxPowerSavingStatus();
assert(status.supported && !status.armed && status.last_error == -708);
assert(status.arm_failures == attempt);
assert(!status.effective_rx_us && !status.effective_sleep_us);
assert(radio._rx_ps_continuous_fallback);
}
assert(radio.events == "ASCASCASC");
for (unsigned poll = 0; poll < 5; ++poll)
assert(radio.startReceivePowerSaving() == RADIOLIB_ERR_NONE);
assert(radio.arm_calls == 3 && radio.stop_calls == 3 && radio.continuous_calls == 8);
assert(radio.getRxPowerSavingStatus().last_error == -708);
}
int main() {
{
RadioLibWrapper radio;
exhaust(radio);
}
{
// Successful continuous RX for calibration must retain the arm failure.
RadioLibWrapper radio;
radio.arm_error = -708;
assert(!radio.startReceivePowerSaving());
radio._nf_calib_active = true;
assert(!radio.startReceivePowerSaving());
const auto status = radio.getRxPowerSavingStatus();
assert(status.last_error == -708 && status.arm_failures == 1);
assert(radio.arm_calls == 1 && radio.continuous_calls == 2);
radio._nf_calib_active = false;
assert(!radio.startReceivePowerSaving() && radio.arm_calls == 2);
}
{
// A real arm success replenishes the consecutive-error budget.
RadioLibWrapper radio;
radio.arm_error = -708;
assert(!radio.startReceivePowerSaving());
assert(!radio.startReceivePowerSaving());
radio.arm_error = 0;
assert(!radio.startReceivePowerSaving());
auto status = radio.getRxPowerSavingStatus();
assert(status.armed && status.last_error == 0 && status.arm_failures == 2);
assert(status.effective_rx_us == 48000 && status.effective_sleep_us == 47000);
assert(status.effective_rx_us != radio._rx_ps_rx_us);
assert(status.effective_sleep_us != radio._rx_ps_sleep_us);
radio.arm_error = -707;
for (unsigned attempt = 1; attempt <= 3; ++attempt) {
assert(!radio.startReceivePowerSaving());
assert(radio.arm_calls == 3 + attempt);
}
assert(!radio.startReceivePowerSaving() && radio.arm_calls == 6);
assert(radio.getRxPowerSavingStatus().arm_failures == 5);
}
{
RadioLibWrapper radio;
assert(!radio.startReceivePowerSaving());
radio._nf_calib_active = radio.packet_pending = true;
assert(!radio.startReceivePowerSaving());
assert(radio.arm_calls == 1 && radio.stop_calls == 0 && radio.continuous_calls == 0);
assert(radio.getRxPowerSavingStatus().armed);
radio.packet_pending = false;
assert(!radio.startReceivePowerSaving());
auto status = radio.getRxPowerSavingStatus();
assert(!status.armed && !status.effective_rx_us && !status.effective_sleep_us);
assert(radio.events == "ASC");
}
{
RadioLibWrapper radio;
assert(!radio.startReceivePowerSaving());
radio.stop_error = -705;
radio.stopReceiveDutyCycle();
auto status = radio.getRxPowerSavingStatus();
assert(!status.armed && status.last_error == -705);
assert(!status.effective_rx_us && !status.effective_sleep_us);
}
{
RadioLibWrapper radio;
radio._rx_ps_sleep_us = 2000; // Below the real 2600 us wake transition.
assert(!radio.startReceivePowerSaving());
assert(!radio.arm_calls && radio.continuous_calls == 1);
assert(radio.getRxPowerSavingStatus().last_error == RX_POWERSAVING_ERR_TIMING);
}
{
// Hardware fallback failure must be visible to the ordinary RX retry loop.
RadioLibWrapper radio;
radio.arm_error = -708; radio.continuous_error = -705;
assert(radio.startReceivePowerSaving() == -705);
assert(radio.getRxPowerSavingStatus().last_error == -705);
assert(radio.getRxPowerSavingStatus().arm_failures == 1);
}
{
RadioLibWrapper radio;
radio._rx_ps_rx_us = radio._rx_ps_sleep_us = RX_POWERSAVING_CONTINUOUS_FALLBACK_US;
assert(!radio.startReceivePowerSaving());
assert(!radio.arm_calls && radio.continuous_calls == 1);
assert(radio._rx_ps_continuous_fallback && !radio.getRxPowerSavingStatus().armed);
assert(!radio.getRxPowerSavingStatus().arm_failures);
}
{
RadioLibWrapper radio;
exhaust(radio);
radio.arm_error = 0;
assert(radio.setRxPowerSaving(true, 70000, 90000));
assert(radio.begin_calls == 1 && radio.end_calls == 1 && radio.arm_calls == 4);
auto status = radio.getRxPowerSavingStatus();
assert(status.armed && status.last_error == 0 && status.arm_failures == 3);
assert(radio._rx_ps_rx_us == 70000 && radio._rx_ps_sleep_us == 90000);
}
{
RadioLibWrapper radio;
exhaust(radio);
radio.n_recv = 2; radio.n_sent = 3; radio.n_recv_errors = 4;
radio.resetStats();
assert(!radio.n_recv && !radio.n_sent && !radio.n_recv_errors);
assert(!radio.getRxPowerSavingStatus().arm_failures);
radio.arm_error = 0;
assert(!radio.startReceivePowerSaving() && radio.arm_calls == 4);
assert(radio.getRxPowerSavingStatus().armed);
}
std::puts("PASS: production RXPS fallback, retry, status and recovery");
}
'''
def production_source():
wrapper = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text()
header = (ROOT / "src/helpers/radiolib/RadioLibWrappers.h").read_text()
methods = "\n\n".join(body(wrapper, signature) for signature in (
"int RadioLibWrapper::startReceivePowerSaving()",
"void RadioLibWrapper::stopReceiveDutyCycle()",
"bool RadioLibWrapper::setRxPowerSaving(",
"RxPowerSavingStatus RadioLibWrapper::getRxPowerSavingStatus()"))
return HARNESS.replace("@METHODS@", methods).replace(
"@RESET_STATS@", body(header, "void resetStats()"))
class RxPowerSavingRuntimeTests(unittest.TestCase):
def run_harness(self, source, expect_pass=True):
with tempfile.TemporaryDirectory(prefix="meshcore-rxps-runtime-") as directory:
cpp, binary = Path(directory) / "runtime.cpp", Path(directory) / "runtime"
cpp.write_text(source, encoding="ascii")
build = subprocess.run([
os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
"-I", str(ROOT / "src"), str(cpp), "-o", str(binary)],
capture_output=True, text=True, timeout=60)
self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
run = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
if expect_pass:
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
self.assertIn("PASS:", run.stdout)
else:
self.assertNotEqual(run.returncode, 0, "negative control unexpectedly passed")
self.assertIn("Assertion", run.stderr)
def test_actual_wrapper_rxps_retry_status_and_recovery(self):
self.run_harness(production_source())
def test_retry_budget_and_calibration_error_negative_controls(self):
source = production_source()
mutations = (
("|| !_rx_ps_arm_retry.canAttempt()", "|| false"),
("_rx_ps_arm_retry.recordSuccess();", "/* omit successful-arm budget reset */"),
("if (err != RADIOLIB_ERR_NONE || !_rx_ps_enabled) _rx_ps_last_error = err;",
"_rx_ps_last_error = err;"),
)
for original, replacement in mutations:
with self.subTest(mutation=original):
self.assertEqual(source.count(original), 1)
self.run_harness(source.replace(original, replacement, 1), expect_pass=False)
if __name__ == "__main__":
unittest.main()
+14
View File
@@ -55,6 +55,20 @@ class Recipes:
class UpstreamPowerPolicyMergeTests(unittest.TestCase):
def test_c6_keeps_arduino3_and_esp32_ota_guards(self):
recipes = Recipes()
self.assertIn("powersaving-arduino-lib/releases/download/2.0.17/",
recipes.value("esp32_base", "platform_packages"))
self.assertEqual(recipes.value("esp32c6_base", "platform_packages"), "")
self.assertIn("53.03.13-1", recipes.value("esp32c6_base", "platform"))
for option, expected in (
("build_flags", "-D ENABLE_OTA=1"),
("build_src_filter", "+<helpers/ota/*.cpp>"),
("extra_scripts", "pre:scripts/esp32_usb_session_fix.py"),
("extra_scripts", "post:scripts/check_firmware_ram.py")):
with self.subTest(option=option, expected=expected):
self.assertIn(expected, recipes.value("esp32c6_base", option))
def test_all_eleven_board_bases_keep_chemistry_power_policy(self):
recipes = Recipes()
for directory, section in BOARDS.items():
+1
View File
@@ -13,6 +13,7 @@ build_flags = ${nrf52_base.build_flags}
-D PIN_STATUS_LED=LED_STATUS
-D RADIO_CLASS=CustomLR1110
-D WRAPPER_CLASS=CustomLR1110Wrapper
-D USE_LR1110
-D LORA_TX_POWER=22
-D RF_SWITCH_TABLE
-D RX_BOOSTED_GAIN=true