Merge RX duty-cycle power saving into keymindCascade

This commit is contained in:
mikecarper committed 2026-07-11 22:12:20 -07:00
commit 0ede4e2066
18 files changed
+865 -52

No files matched your search

+59
View File
@@ -3,6 +3,16 @@
#include <Arduino.h> // needed for PlatformIO
#include <Mesh.h>
#ifndef RXPS_FIXED_ENABLED
#define RXPS_FIXED_ENABLED 1
#endif
#ifndef RXPS_FIXED_LEVEL
#define RXPS_FIXED_LEVEL 2
#endif
#ifndef RXPS_FIXED_PREAMBLE
#define RXPS_FIXED_PREAMBLE 16
#endif
#define CMD_APP_START 1
#define CMD_SEND_TXT_MSG 2
#define CMD_SEND_CHANNEL_TXT_MSG 3
@@ -301,6 +311,49 @@ int MyMesh::getInterferenceThreshold() const {
return 0; // disabled for now, until currentRSSI() problem is resolved
}
#if RXPS_FIXED_ENABLED
static uint32_t ceilPositiveFloat(float value) {
uint32_t rounded = (uint32_t)value;
return value > (float)rounded ? rounded + 1 : rounded;
}
static bool calcFixedRxPowerSaving(uint8_t sf, float bw, uint32_t* rx_us, uint32_t* sleep_us) {
if (RXPS_FIXED_LEVEL < 1 || RXPS_FIXED_LEVEL > 10 || sf < 5 || sf > 12 ||
bw <= 0.0f || (RXPS_FIXED_PREAMBLE != 16 && RXPS_FIXED_PREAMBLE != 32)) {
return false;
}
const float symbol_us = (1000.0f * (float)(1UL << sf)) / bw;
const float amount = (float)(RXPS_FIXED_LEVEL - 1) / 9.0f;
const float rx_start_symbols = RXPS_FIXED_PREAMBLE == 16 ? 12.0f : 16.0f;
const float sleep_start_symbols = RXPS_FIXED_PREAMBLE == 16 ? 2.0f : 15.0f;
const float rx_edge_symbols = 8.0f;
const float sleep_edge_symbols = (float)RXPS_FIXED_PREAMBLE + 4.25f - 8.0f;
const float rx_symbols = rx_start_symbols + amount * (rx_edge_symbols - rx_start_symbols);
const float sleep_symbols = sleep_start_symbols + amount * (sleep_edge_symbols - sleep_start_symbols);
*rx_us = ceilPositiveFloat(rx_symbols * symbol_us);
*sleep_us = (uint32_t)(sleep_symbols * symbol_us);
return true;
}
static void applyFixedRxPowerSaving(uint8_t sf, float bw) {
uint32_t rx_us, sleep_us;
if (!calcFixedRxPowerSaving(sf, bw, &rx_us, &sleep_us)) {
MESH_DEBUG_PRINTLN("RX Power Saving fixed profile invalid");
return;
}
bool ok = radio_driver.setRxPowerSaving(true, rx_us, sleep_us);
MESH_DEBUG_PRINTLN("RX Power Saving fixed level %d p%d: %s (%lu/%lu us)",
RXPS_FIXED_LEVEL,
RXPS_FIXED_PREAMBLE,
ok ? "Enabled" : "Unsupported",
(unsigned long)rx_us,
(unsigned long)sleep_us);
}
#endif
int MyMesh::calcRxDelay(float score, uint32_t air_time) const {
if (_prefs.rx_delay_base <= 0.0f) return 0;
return (int)((pow(_prefs.rx_delay_base, 0.85f - score) - 1.0) * air_time);
@@ -1079,6 +1132,9 @@ void MyMesh::begin(bool has_display) {
radio_driver.setTxPower(_prefs.tx_power_dbm);
radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain);
board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain);
#if RXPS_FIXED_ENABLED
applyFixedRxPowerSaving(_prefs.sf, _prefs.bw);
#endif
MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s",
radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled");
// NOTE: no FEM LNA wiring here — companion has its own NodePrefs without
@@ -1510,6 +1566,9 @@ void MyMesh::handleCmdFrame(size_t len) {
savePrefs();
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
#if RXPS_FIXED_ENABLED
applyFixedRxPowerSaving(_prefs.sf, _prefs.bw);
#endif
MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf,
(uint32_t)cr);
+22 -8
View File
@@ -1145,14 +1145,8 @@ static uint8_t getRetryLogCodingRate(const mesh::Packet* packet, uint8_t default
}
static uint16_t getRetryLogPreambleLength(const mesh::Packet* packet, uint16_t default_preamble_len) {
if (packet == NULL || default_preamble_len <= 16 || !(packet->tx_cr == 4 || packet->tx_cr == 5)) {
return default_preamble_len;
}
bool has_direct_path = packet->getPathHashCount() > 0
|| (packet->getPayloadType() == PAYLOAD_TYPE_TRACE && packet->payload_len > 9);
if (packet->isRouteDirect() && has_direct_path) {
return 16;
if (packet != NULL && packet->tx_cr >= 4 && packet->tx_cr <= 8) {
return 32;
}
return default_preamble_len;
}
@@ -2294,6 +2288,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
#endif
// Observer defaults (radio_watchdog, alert.*, snmp.*) moved to applyMQTTDefaults()
// in MQTTDefaults.h — they live in /mqtt_prefs now, not NodePrefs.
_prefs.rx_ps_rx_us = RX_POWERSAVING_DEFAULT_RX_US;
_prefs.rx_ps_sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US;
// bridge defaults
_prefs.bridge_enabled = 1; // enabled
@@ -2418,6 +2414,7 @@ void MyMesh::begin(FILESYSTEM *fs) {
MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s",
radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled");
board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain); // LoRa FEM LNA (FEM boards only)
setRxPowerSaving(_prefs.rx_powersaving_enabled, _prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us);
updateAdvertTimer();
updateFloodAdvertTimer();
@@ -3097,6 +3094,21 @@ void MyMesh::setTxPower(int8_t power_dbm) {
radio_driver.setTxPower(power_dbm);
}
bool MyMesh::setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) {
bool ok = radio_driver.setRxPowerSaving(enable, rx_us, sleep_us);
MESH_DEBUG_PRINTLN("RX Power Saving: %s (%lu/%lu us)%s",
enable ? "Enabled" : "Disabled",
(unsigned long)rx_us,
(unsigned long)sleep_us,
ok ? "" : " unsupported");
return ok;
}
void MyMesh::getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) {
*soft = radio_driver.getRxPsWatchdogSoftCount();
*hard = radio_driver.getRxPsWatchdogHardCount();
}
bool MyMesh::setRxBoostedGain(bool enable) {
return radio_driver.setRxBoostedGainMode(enable);
}
@@ -3959,6 +3971,8 @@ bool MyMesh::hasPendingWork() const {
const BridgeBase* active_bridge = activeBridge();
if (active_bridge && active_bridge->isRunning()) return true;
#endif
if (radio_driver.isWatchdogObserving()) return true; // keep MCU awake for one radio duty cycle
if (radio_driver.isCalibratingNoiseFloor()) return true; // keep MCU awake for the noise-floor window
if (_mgr->getOutboundTotal() > 0) return true;
if (isMillisTimerDue(next_flood_advert) || isMillisTimerDue(next_local_advert)) return true;
if (isMillisTimerDue(dirty_contacts_expiry)) return true;
+2
View File
@@ -387,6 +387,8 @@ public:
void dumpLogFile() override;
void setTxPower(int8_t power_dbm) override;
bool setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) override;
void getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) override;
void formatNeighborsReply(char *reply) override;
void removeNeighbor(const uint8_t* pubkey, int key_len) override;
void formatStatsReply(char *reply) override;
+14
View File
@@ -675,6 +675,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
_prefs.interference_threshold = 0; // disabled
_prefs.radio_fem_rxgain = 1; // LoRa FEM RX gain on by default (FEM boards)
_prefs.cad_enabled = 0; // hardware CAD before TX (off by default; 'set cad on')
_prefs.rx_ps_rx_us = RX_POWERSAVING_DEFAULT_RX_US;
_prefs.rx_ps_sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US;
#ifdef ROOM_PASSWORD
StrHelper::strncpy(_prefs.guest_password, ROOM_PASSWORD, sizeof(_prefs.guest_password));
#endif
@@ -738,6 +740,7 @@ void MyMesh::begin(FILESYSTEM *fs) {
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
radio_driver.setTxPower(_prefs.tx_power_dbm);
board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain); // LoRa FEM LNA (FEM boards only)
setRxPowerSaving(_prefs.rx_powersaving_enabled, _prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us);
updateAdvertTimer();
updateFloodAdvertTimer();
@@ -899,6 +902,15 @@ void MyMesh::setTxPower(int8_t power_dbm) {
radio_driver.setTxPower(power_dbm);
}
bool MyMesh::setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) {
return radio_driver.setRxPowerSaving(enable, rx_us, sleep_us);
}
void MyMesh::getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) {
*soft = radio_driver.getRxPsWatchdogSoftCount();
*hard = radio_driver.getRxPsWatchdogHardCount();
}
void MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) {
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
IdentityStore store(*_fs, "");
@@ -1171,6 +1183,8 @@ bool MyMesh::hasPendingWork() const {
#if defined(WITH_BRIDGE)
if (bridge && bridge->isRunning()) return true; // bridge needs WiFi radio, can't sleep
#endif
if (radio_driver.isWatchdogObserving()) return true; // keep MCU awake for one radio duty cycle
if (radio_driver.isCalibratingNoiseFloor()) return true; // keep MCU awake for the noise-floor window
if (_mgr->getOutboundTotal() > 0) return true;
if (acl.getNumClients() > 0 && isMillisTimerDue(next_push)) return true;
if (isMillisTimerDue(next_flood_advert) || isMillisTimerDue(next_local_advert)) return true;
+2
View File
@@ -230,6 +230,8 @@ public:
void dumpLogFile() override;
void setTxPower(int8_t power_dbm) override;
bool setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) override;
void getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) override;
void formatNeighborsReply(char *reply) override {
strcpy(reply, "not supported");
+12
View File
@@ -769,6 +769,8 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise
_prefs.interference_threshold = 0; // disabled
_prefs.radio_fem_rxgain = 1; // LoRa FEM RX gain on by default (FEM boards)
_prefs.cad_enabled = 0; // hardware CAD before TX (off by default; 'set cad on')
_prefs.rx_ps_rx_us = RX_POWERSAVING_DEFAULT_RX_US;
_prefs.rx_ps_sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US;
// GPS defaults
_prefs.gps_enabled = 0;
@@ -812,6 +814,7 @@ void SensorMesh::begin(FILESYSTEM* fs) {
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
radio_driver.setTxPower(_prefs.tx_power_dbm);
board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain); // LoRa FEM LNA (FEM boards only)
setRxPowerSaving(_prefs.rx_powersaving_enabled, _prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us);
updateAdvertTimer();
updateFloodAdvertTimer();
@@ -891,6 +894,15 @@ void SensorMesh::setTxPower(int8_t power_dbm) {
radio_driver.setTxPower(power_dbm);
}
bool SensorMesh::setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) {
return radio_driver.setRxPowerSaving(enable, rx_us, sleep_us);
}
void SensorMesh::getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) {
*soft = radio_driver.getRxPsWatchdogSoftCount();
*hard = radio_driver.getRxPsWatchdogHardCount();
}
void SensorMesh::formatStatsReply(char *reply) {
StatsFormatHelper::formatCoreStats(reply, board, *_ms, _err_flags, _mgr);
}
+2
View File
@@ -68,6 +68,8 @@ public:
void eraseLogFile() override { }
void dumpLogFile() override { }
void setTxPower(int8_t power_dbm) override;
bool setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) override;
void getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) override;
void formatNeighborsReply(char *reply) override {
strcpy(reply, "not supported");
}
+3 -9
View File
@@ -381,25 +381,19 @@ void Dispatcher::checkSend() {
outbound_restore_cr = 0;
outbound_restore_preamble_len = 0;
uint8_t default_cr = getDefaultTxCodingRate();
bool using_low_retry_cr = false;
bool is_retry = outbound->tx_cr >= 4 && outbound->tx_cr <= 8;
if (outbound->tx_cr >= 4 && outbound->tx_cr <= 8 && default_cr >= 4 && default_cr <= 8
&& outbound->tx_cr != default_cr) {
if (_radio->setCodingRate(outbound->tx_cr)) {
outbound_restore_cr = default_cr;
using_low_retry_cr = outbound->tx_cr == 4 || outbound->tx_cr == 5;
max_airtime = _radio->getEstAirtimeFor(len)*3/2;
} else {
MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): WARN: failed to set packet CR%d", getLogDateTime(), (uint32_t)outbound->tx_cr);
}
} else if (outbound->tx_cr == 4 || outbound->tx_cr == 5) {
using_low_retry_cr = outbound->tx_cr == default_cr;
}
bool has_direct_path = outbound->getPathHashCount() > 0
|| (outbound->getPayloadType() == PAYLOAD_TYPE_TRACE && outbound->payload_len > 9);
if (outbound->isRouteDirect() && has_direct_path
&& using_low_retry_cr) {
if (is_retry) {
uint16_t default_preamble_len = _radio->getDefaultPreambleLength();
if (default_preamble_len > 16 && _radio->setPreambleLength(16)) {
if (default_preamble_len != 32 && _radio->setPreambleLength(32)) {
outbound_restore_preamble_len = default_preamble_len;
max_airtime = _radio->getEstAirtimeFor(len)*3/2;
}
+3
View File
@@ -86,6 +86,9 @@ public:
virtual bool isInRecvMode() const = 0;
virtual bool supportsRxPowerSaving() const { return false; }
virtual bool setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) { return !enabled; }
/**
* \returns true if the radio is currently mid-receive of a packet.
*/
+2
View File
@@ -1364,6 +1364,7 @@ void Mesh::armFloodRetryOnSendComplete(const Packet* packet) {
}
*retry = *packet;
retry->tx_cr = getDefaultTxCodingRate();
uint32_t retry_delay = getFloodRetryAttemptDelay(packet, _flood_retries[i].retry_attempts_sent);
if (queueOutboundPacket(retry, _flood_retries[i].priority, retry_delay)) {
_flood_retries[i].packet = retry;
@@ -1394,6 +1395,7 @@ void Mesh::armFloodRetryOnSendComplete(const Packet* packet) {
}
*retry = *packet;
retry->tx_cr = getDefaultTxCodingRate();
if (queueOutboundPacket(retry, _flood_retries[i].priority, _flood_retries[i].retry_delay)) {
unsigned long now = _ms->getMillis();
_flood_retries[i].packet = retry;
+250 -4
View File
@@ -313,6 +313,80 @@ static const char* retryPresetName(uint8_t preset) {
}
}
static bool isValidRxPowerSavingPeriod(uint32_t us) {
return us >= RX_POWERSAVING_MIN_PERIOD_US && us <= RX_POWERSAVING_MAX_PERIOD_US;
}
// MeshCore preamble convention used for the RX powersaving timing calculation.
// Must stay in sync with RadioLibWrapper::preambleLengthForSF() (the value the
// radio actually transmits); kept local here because CommonCLI is radio-agnostic.
static uint16_t rxPowerSavingPreambleForSF(uint8_t sf) {
return sf <= 8 ? 32 : 16;
}
static bool isNumeric(const char* sp) {
if (!sp || !*sp) return false;
while (*sp) {
if (*sp < '0' || *sp > '9') return false;
sp++;
}
return true;
}
static uint32_t ceilPositiveFloat(float value) {
uint32_t rounded = (uint32_t)value;
return value > (float)rounded ? rounded + 1 : rounded;
}
static bool calcRxPowerSavingLevel(uint32_t level, uint8_t sf, float bw, uint32_t preamble,
uint32_t* rx_us, uint32_t* sleep_us) {
if (level < 1 || level > 10 || sf < 5 || sf > 12 || bw <= 0.0f || (preamble != 16 && preamble != 32)) {
return false;
}
const float symbol_us = (1000.0f * (float)(1UL << sf)) / bw;
const float amount = (float)(level - 1) / 9.0f;
const float rx_start_symbols = preamble == 16 ? 12.0f : 16.0f;
const float sleep_start_symbols = preamble == 16 ? 2.0f : 15.0f;
const float rx_edge_symbols = 8.0f;
const float sleep_edge_symbols = (float)preamble + 4.25f - 8.0f;
const float rx_symbols = rx_start_symbols + amount * (rx_edge_symbols - rx_start_symbols);
const float sleep_symbols = sleep_start_symbols + amount * (sleep_edge_symbols - sleep_start_symbols);
*rx_us = ceilPositiveFloat(rx_symbols * symbol_us);
*sleep_us = (uint32_t)(sleep_symbols * symbol_us);
return true;
}
static void ensureRxPowerSavingDefaults(NodePrefs* prefs) {
if (!isValidRxPowerSavingPeriod(prefs->rx_ps_rx_us)) {
prefs->rx_ps_rx_us = RX_POWERSAVING_DEFAULT_RX_US;
}
if (!isValidRxPowerSavingPeriod(prefs->rx_ps_sleep_us)) {
prefs->rx_ps_sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US;
}
}
// Recomputes rx_ps_rx_us/rx_ps_sleep_us from the stored level and the current
// 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.
static bool recalcRxPowerSavingFromLevel(NodePrefs* prefs) {
if (prefs->rx_ps_level < 1 || prefs->rx_ps_level > 10) return false; // manual: nothing to recompute
uint32_t preamble = prefs->rx_ps_preamble ? prefs->rx_ps_preamble
: rxPowerSavingPreambleForSF(prefs->sf);
uint32_t rx_us, sleep_us;
if (!calcRxPowerSavingLevel(prefs->rx_ps_level, prefs->sf, prefs->bw, preamble, &rx_us, &sleep_us)) {
return false;
}
if (!isValidRxPowerSavingPeriod(rx_us) || !isValidRxPowerSavingPeriod(sleep_us)) {
return false;
}
prefs->rx_ps_rx_us = rx_us;
prefs->rx_ps_sleep_us = sleep_us;
return true;
}
static void markDirectRetryPrefsValid(NodePrefs* prefs) {
prefs->direct_retry_prefs_magic[0] = DIRECT_RETRY_PREFS_MAGIC_0;
prefs->direct_retry_prefs_magic[1] = DIRECT_RETRY_PREFS_MAGIC_1;
@@ -763,6 +837,11 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
// (upstream defaults: FEM RX gain on, CAD off) — overwritten below if present.
_prefs->radio_fem_rxgain = 1;
_prefs->cad_enabled = 0;
_prefs->rx_powersaving_enabled = 0;
_prefs->rx_ps_rx_us = RX_POWERSAVING_DEFAULT_RX_US;
_prefs->rx_ps_sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US;
_prefs->rx_ps_level = 0;
_prefs->rx_ps_preamble = 0;
#if defined(ENABLE_OTA)
// OTA settings were appended after Keymind's retry/flood tail. Initialize them
// before reading so older and legacy preference files remain conservative.
@@ -987,6 +1066,36 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
}
// next: 811
#endif
// RXPS is stored at a fixed offset after the reserved OTA tail. This keeps
// both old OTA and non-OTA /com_prefs files unambiguous.
bool has_rxps_tail = file.available() >= 11;
#if !defined(ENABLE_OTA)
const size_t ota_tail_size = 136;
const size_t padded_rxps_tail_size = sizeof(_prefs->rx_powersaving_enabled)
+ sizeof(_prefs->rx_ps_rx_us) + sizeof(_prefs->rx_ps_sleep_us)
+ sizeof(_prefs->rx_ps_level) + sizeof(_prefs->rx_ps_preamble);
if (file.available() >= (int)(ota_tail_size + padded_rxps_tail_size)) {
size_t remaining = ota_tail_size;
while (remaining > 0) {
size_t n = remaining > sizeof(pad) ? sizeof(pad) : remaining;
file.read(pad, n);
remaining -= n;
}
has_rxps_tail = true;
} else {
has_rxps_tail = false;
}
#endif
const size_t rxps_tail_size = sizeof(_prefs->rx_powersaving_enabled)
+ sizeof(_prefs->rx_ps_rx_us) + sizeof(_prefs->rx_ps_sleep_us)
+ sizeof(_prefs->rx_ps_level) + sizeof(_prefs->rx_ps_preamble);
if (has_rxps_tail && file.available() >= (int)rxps_tail_size) {
file.read((uint8_t *)&_prefs->rx_powersaving_enabled, sizeof(_prefs->rx_powersaving_enabled));
file.read((uint8_t *)&_prefs->rx_ps_rx_us, sizeof(_prefs->rx_ps_rx_us));
file.read((uint8_t *)&_prefs->rx_ps_sleep_us, sizeof(_prefs->rx_ps_sleep_us));
file.read((uint8_t *)&_prefs->rx_ps_level, sizeof(_prefs->rx_ps_level));
file.read((uint8_t *)&_prefs->rx_ps_preamble, sizeof(_prefs->rx_ps_preamble));
}
}
// sanitise bad pref values
@@ -1071,6 +1180,13 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
if (_prefs->ota_max_hops > 8) _prefs->ota_max_hops = 3; // 0=direct only; cap absurd reach
if (_prefs->ota_signer_count > 4) _prefs->ota_signer_count = 0; // corrupt count -> drop keys
#endif
_prefs->rx_powersaving_enabled = constrain(_prefs->rx_powersaving_enabled, 0, 1);
_prefs->rx_ps_level = constrain(_prefs->rx_ps_level, 0, 10);
if (_prefs->rx_ps_preamble != 16 && _prefs->rx_ps_preamble != 32) {
_prefs->rx_ps_preamble = 0; // 0 = auto (derive from SF)
}
ensureRxPowerSavingDefaults(_prefs);
recalcRxPowerSavingFromLevel(_prefs); // retune level-based timings to the loaded SF/BW
file.close();
}
@@ -1132,9 +1248,8 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) {
file.write((uint8_t *)&_prefs->discovery_mod_timestamp, sizeof(_prefs->discovery_mod_timestamp)); // 162
file.write((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166
file.write((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170
// MQTT/observer settings are stored in /mqtt_prefs, not here. No zero-gap is
// written anymore — /com_prefs holds the upstream fields plus the keymind
// retry tail below (loadPrefsInt reads the same sequence; keep in sync).
// MQTT/observer settings are stored in /mqtt_prefs, not here. /com_prefs
// holds the upstream fields plus the keymind retry tail below.
file.write((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290
file.write((uint8_t *)&_prefs->flood_max_unscoped, sizeof(_prefs->flood_max_unscoped)); // 291
file.write((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292
@@ -1178,7 +1293,28 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) {
file.write((uint8_t *)&_prefs->ota_advert_interval, sizeof(_prefs->ota_advert_interval)); // 808
file.write((uint8_t *)&_prefs->ota_max_hops, sizeof(_prefs->ota_max_hops)); // 810
// next: 811
#else
// Reserve the OTA tail so RXPS has the same offset in every build. Write
// OTA's normal defaults so a later OTA-enabled firmware reads sane values.
file.write(pad, 3); // autofetch, autoinstall, signer_count
for (size_t remaining = 128; remaining > 0; ) {
size_t n = remaining > sizeof(pad) ? sizeof(pad) : remaining;
file.write(pad, n);
remaining -= n;
}
const uint16_t ota_checkpoint_blocks = 4;
const uint16_t ota_advert_interval = 0;
const uint8_t ota_max_hops = 3;
file.write((uint8_t *)&ota_checkpoint_blocks, sizeof(ota_checkpoint_blocks));
file.write((uint8_t *)&ota_advert_interval, sizeof(ota_advert_interval));
file.write((uint8_t *)&ota_max_hops, sizeof(ota_max_hops));
#endif
file.write((uint8_t *)&_prefs->rx_powersaving_enabled, sizeof(_prefs->rx_powersaving_enabled)); // 811
file.write((uint8_t *)&_prefs->rx_ps_rx_us, sizeof(_prefs->rx_ps_rx_us)); // 812
file.write((uint8_t *)&_prefs->rx_ps_sleep_us, sizeof(_prefs->rx_ps_sleep_us)); // 816
file.write((uint8_t *)&_prefs->rx_ps_level, sizeof(_prefs->rx_ps_level)); // 820
file.write((uint8_t *)&_prefs->rx_ps_preamble, sizeof(_prefs->rx_ps_preamble)); // 821
// next: 822
file.close();
}
@@ -1992,6 +2128,105 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
} else {
strcpy(reply, "Error: state must be on or off");
}
} else if (memcmp(config, "radio.rxps ", 11) == 0) {
const char* value = &config[11];
uint8_t enable = _prefs->rx_powersaving_enabled;
uint32_t rx_us = _prefs->rx_ps_rx_us;
uint32_t sleep_us = _prefs->rx_ps_sleep_us;
uint32_t level = 0;
uint32_t preamble = rxPowerSavingPreambleForSF(_prefs->sf);
bool level_requested = false;
bool preamble_overridden = false;
ensureRxPowerSavingDefaults(_prefs);
rx_us = _prefs->rx_ps_rx_us;
sleep_us = _prefs->rx_ps_sleep_us;
if (strcmp(value, "off") == 0) {
enable = 0;
} else if (strcmp(value, "on") == 0 || strcmp(value, "conservative") == 0) {
enable = 1;
level = RX_POWERSAVING_CONSERVATIVE_LEVEL;
preamble = RX_POWERSAVING_PROFILE_PREAMBLE;
level_requested = true;
preamble_overridden = true;
} else if (strcmp(value, "balanced") == 0) {
enable = 1;
level = RX_POWERSAVING_BALANCED_LEVEL;
preamble = RX_POWERSAVING_PROFILE_PREAMBLE;
level_requested = true;
preamble_overridden = true;
} else {
StrHelper::strncpy(tmp, value, sizeof(tmp));
const char *parts[4];
int num = mesh::Utils::parseTextParts(tmp, parts, 4, ' ');
if (num == 1 && isNumeric(parts[0])) {
level = _atoi(parts[0]);
level_requested = true;
enable = 1;
} else if (num == 2 && strcmp(parts[0], "level") == 0 && isNumeric(parts[1])) {
level = _atoi(parts[1]);
level_requested = true;
enable = 1;
} else if (num == 4 && strcmp(parts[0], "level") == 0 && isNumeric(parts[1]) &&
strcmp(parts[2], "preamble") == 0 && isNumeric(parts[3])) {
level = _atoi(parts[1]);
preamble = _atoi(parts[3]);
level_requested = true;
preamble_overridden = true;
enable = 1;
} else if (num == 2 && isNumeric(parts[0]) && isNumeric(parts[1])) {
rx_us = _atoi(parts[0]);
sleep_us = _atoi(parts[1]);
enable = 1;
} else {
strcpy(reply, "ERROR: use off|on|conservative|balanced|level <1-10>|<rx_us> <sleep_us>");
return;
}
}
if (level_requested && !calcRxPowerSavingLevel(level, _prefs->sf, _prefs->bw, preamble, &rx_us, &sleep_us)) {
strcpy(reply, "ERROR: level range is 1-10; preamble is 16 or 32");
return;
}
if (!isValidRxPowerSavingPeriod(rx_us) || !isValidRxPowerSavingPeriod(sleep_us)) {
sprintf(reply, "ERROR: range is %lu-%lu us",
(unsigned long)RX_POWERSAVING_MIN_PERIOD_US,
(unsigned long)RX_POWERSAVING_MAX_PERIOD_US);
return;
}
if (!_callbacks->setRxPowerSaving(enable, rx_us, sleep_us)) {
strcpy(reply, "ERROR: RX powersaving unsupported");
return;
}
_prefs->rx_powersaving_enabled = enable;
_prefs->rx_ps_rx_us = rx_us;
_prefs->rx_ps_sleep_us = sleep_us;
if (level_requested) {
// Remember the intent so the timings can auto-retune when SF/BW change.
_prefs->rx_ps_level = level;
_prefs->rx_ps_preamble = preamble_overridden ? preamble : 0; // 0 = auto (derive from SF)
} else if (strcmp(value, "off") != 0) {
// manual <rx_us> <sleep_us> timings are fixed, not level-derived
// (the named profiles set level_requested and are handled above)
_prefs->rx_ps_level = 0;
_prefs->rx_ps_preamble = 0;
}
savePrefs();
if (level_requested) {
sprintf(reply, "OK - level %lu,%s,%lu,%lu,preamble=%lu",
(unsigned long)level,
enable ? "on" : "off",
(unsigned long)rx_us,
(unsigned long)sleep_us,
(unsigned long)preamble);
} else {
sprintf(reply, "OK - %s,%lu,%lu", enable ? "on" : "off",
(unsigned long)rx_us, (unsigned long)sleep_us);
}
} else if (memcmp(config, "radio ", 6) == 0) {
strcpy(tmp, &config[6]);
const char *parts[4];
@@ -2005,8 +2240,11 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
_prefs->cr = cr;
_prefs->freq = freq;
_prefs->bw = bw;
// Retune level-based RX powersaving to the new SF/BW. Persist only; the
// radio itself is "reboot to apply", and begin() re-arms the timings then.
bool rxps_retuned = recalcRxPowerSavingFromLevel(_prefs);
_callbacks->savePrefs();
strcpy(reply, "OK - reboot to apply");
strcpy(reply, rxps_retuned ? "OK - reboot to apply (rxps retuned)" : "OK - reboot to apply");
} else {
strcpy(reply, "Error, invalid radio params");
}
@@ -2573,6 +2811,14 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
_callbacks->formatScheduledRadioParams(true, skipSpacesConst(&config[11]), reply);
} else if (memcmp(config, "radioat", 7) == 0 && (config[7] == 0 || config[7] == ' ')) {
_callbacks->formatScheduledRadioParams(false, skipSpacesConst(&config[7]), reply);
} else if (memcmp(config, "radio.rxps", 10) == 0) {
ensureRxPowerSavingDefaults(_prefs);
sprintf(reply, "> %s,%lu,%lu", _prefs->rx_powersaving_enabled ? "on" : "off",
(unsigned long)_prefs->rx_ps_rx_us, (unsigned long)_prefs->rx_ps_sleep_us);
} else if (memcmp(config, "rxps.wd", 7) == 0) {
uint32_t wd_soft, wd_hard;
_callbacks->getRxPsWatchdogCounts(&wd_soft, &wd_hard);
sprintf(reply, "> soft=%lu,hard=%lu", (unsigned long)wd_soft, (unsigned long)wd_hard);
} else if (memcmp(config, "radio", 5) == 0) {
char freq[16], bw[16];
strcpy(freq, StrHelper::ftoa(_prefs->freq));
+26
View File
@@ -102,6 +102,18 @@
#define TELEMETRY_ACCESS_ALL 0
#define TELEMETRY_ACCESS_ACL 1
#define RX_POWERSAVING_DEFAULT_RX_US 65625UL
#define RX_POWERSAVING_DEFAULT_SLEEP_US 60000UL
#define RX_POWERSAVING_MIN_PERIOD_US 1000UL
#define RX_POWERSAVING_MAX_PERIOD_US 30000000UL
// The named profiles are level presets pinned to a 16-symbol preamble: most
// deployed senders still transmit 16-symbol preambles regardless of the newer
// SF-based rule (32 for SF <= 8). Revisit once the field has largely migrated.
#define RX_POWERSAVING_CONSERVATIVE_LEVEL 1UL
#define RX_POWERSAVING_BALANCED_LEVEL 5UL
#define RX_POWERSAVING_PROFILE_PREAMBLE 16UL
struct NodePrefs { // persisted to file
float airtime_factor;
char node_name[32];
@@ -196,6 +208,12 @@ struct NodePrefs { // persisted to file
uint16_t ota_advert_interval; // OTA beacon re-advertise cadence (mins); 0=off. Default 1440 (24h, tunable)
uint8_t ota_max_hops; // OTA flood reach in hops; 0=direct only. Default 3 (runtime-tunable)
#endif
uint8_t rx_powersaving_enabled; // boolean
uint32_t rx_ps_rx_us;
uint32_t rx_ps_sleep_us;
uint8_t rx_ps_level; // 0 = manual/explicit us timings; 1..10 = level-derived (auto-retunes on SF/BW change)
uint8_t rx_ps_preamble; // 0 = auto (derive from SF); else 16 or 32 = explicit override for level calc
};
#ifdef WITH_MQTT_BRIDGE
@@ -541,6 +559,14 @@ public:
virtual bool resolveAlertScope(TransportKey& /*dest*/) {
return false; // no op by default
}
virtual bool setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) {
return !enable;
};
virtual void getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) {
*soft = 0; *hard = 0;
};
};
class CommonCLI {
+49 -1
View File
@@ -24,6 +24,45 @@ class CustomLR1110 : public LR1110 {
float getFreqMHz() const { return freqMHz; }
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) {
// RadioLib's LR11x0 duty-cycle path stages RX but does not call
// launchMode(), where the software RF switch is normally set to RX.
// RadioLib 7.6 does not expose LR11x0::tcxoDelay. MeshCore initializes
// these radios with RadioLib's 5000 us default TCXO delay.
const uint32_t transitionTime = 5000 + 1000;
if (sleepPeriod <= transitionTime) {
return RADIOLIB_ERR_INVALID_SLEEP_PERIOD;
}
sleepPeriod -= transitionTime;
uint32_t rxPeriodRaw = (rxPeriod * 32768UL) / 1000000UL;
uint32_t sleepPeriodRaw = (sleepPeriod * 32768UL) / 1000000UL;
if ((rxPeriodRaw & 0xFF000000) || (rxPeriodRaw == 0)) {
return RADIOLIB_ERR_INVALID_RX_PERIOD;
}
if ((sleepPeriodRaw & 0xFF000000) || (sleepPeriodRaw == 0)) {
return RADIOLIB_ERR_INVALID_SLEEP_PERIOD;
}
RadioModeConfig_t cfg = {
.receive = {
.timeout = RADIOLIB_LR11X0_RX_TIMEOUT_INF,
.irqFlags = irqFlags,
.irqMask = irqMask,
.len = 0,
}
};
int16_t state = this->stageMode(RADIOLIB_RADIO_MODE_RX, &cfg);
RADIOLIB_ASSERT(state);
this->mod->setRfSwitchState(Module::MODE_RX);
return this->setRxDutyCycle(rxPeriodRaw, sleepPeriodRaw, RADIOLIB_LR11X0_RX_DUTY_CYCLE_MODE_RX);
}
int16_t setRxBoostedGainMode(bool en) {
_rx_boosted = en;
return LR1110::setRxBoostedGainMode(en);
@@ -31,11 +70,20 @@ class CustomLR1110 : public LR1110 {
bool getRxBoostedGainMode() const { return _rx_boosted; }
// BUSY high means the chip is asleep (RX duty-cycle sleep window) or mid
// command; any SPI access would stall until the chip's next listen window.
bool isChipBusy() {
uint32_t busy = this->mod->getGpio();
return busy != RADIOLIB_NC && this->mod->hal->digitalRead(busy);
}
bool isReceiving() {
if (isChipBusy()) return false; // asleep, cannot be mid-receive
uint16_t irq = getIrqStatus();
bool detected = ((irq & RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID) || (irq & RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED));
return detected;
}
uint8_t getSpreadingFactor() const { return spreadingFactor; }
};
};
@@ -28,6 +28,9 @@ public:
bool isReceivingPacket() override {
return ((CustomLR1110 *)_radio)->isReceiving();
}
bool isChipBusy() override {
return ((CustomLR1110 *)_radio)->isChipBusy();
}
float getCurrentRSSI() override {
float rssi = -110;
((CustomLR1110 *)_radio)->getRssiInst(&rssi);
@@ -44,6 +47,41 @@ public:
_radio->setPreambleLength(preambleLengthForSF(getSpreadingFactor())); // overcomes weird issues with small and big pkts
}
bool supportsRxPowerSaving() const override { return true; }
protected:
int startReceiveMode() override {
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
return _radio->startReceive();
}
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;
}
MESH_DEBUG_PRINTLN("CustomLR1110Wrapper: error: startReceiveDutyCycle(%d), falling back to continuous RX", err);
return _radio->startReceive();
}
public:
float getLastRSSI() const override { return ((CustomLR1110 *)_radio)->getRSSI(); }
float getLastSNR() const override { return ((CustomLR1110 *)_radio)->getSNR(); }
+27
View File
@@ -98,12 +98,39 @@ class CustomSX1262 : public SX1262 {
return true; // success
}
// BUSY high means the chip is asleep (RX duty-cycle sleep window) or mid
// command; any SPI access would stall until the chip's next listen window.
bool isChipBusy() {
uint32_t busy = this->mod->getGpio();
return busy != RADIOLIB_NC && this->mod->hal->digitalRead(busy);
}
bool isReceiving() {
if (isChipBusy()) return false; // asleep, cannot be mid-receive
uint16_t irq = getIrqFlags();
bool detected = (irq & SX126X_IRQ_HEADER_VALID) || (irq & SX126X_IRQ_PREAMBLE_DETECTED);
return detected;
}
// Port of Semtech's sx126x_stop_rtc() (same registers as RadioLib's
// fixImplicitTimeout / datasheet errata 15.3): after duty-cycle RX ends via
// RxDone or SetStandby, the internal RTC keeps running and its pending
// event can silently knock a subsequently started RX back to standby with
// no IRQ, leaving the node deaf. Must be called before re-arming RX.
int16_t stopRTC() {
uint8_t rtcStop = 0x00;
int16_t state = writeRegister(RADIOLIB_SX126X_REG_RTC_CTRL, &rtcStop, 1);
RADIOLIB_ASSERT(state);
uint8_t rtcEvent = 0;
state = readRegister(RADIOLIB_SX126X_REG_EVENT_MASK, &rtcEvent, 1);
RADIOLIB_ASSERT(state);
rtcEvent |= 0x02; // clear the RX timeout event
return writeRegister(RADIOLIB_SX126X_REG_EVENT_MASK, &rtcEvent, 1);
}
bool getRxBoostedGainMode() {
uint8_t rxGain = 0;
readRegister(RADIOLIB_SX126X_REG_RX_GAIN, &rxGain, 1);
+51 -1
View File
@@ -13,6 +13,7 @@ public:
CustomSX1262Wrapper(CustomSX1262& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { }
void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override {
cacheParams(freq, bw, sf, cr);
((CustomSX1262 *)_radio)->setFrequency(freq);
((CustomSX1262 *)_radio)->setSpreadingFactor(sf);
((CustomSX1262 *)_radio)->setBandwidth(bw);
@@ -24,9 +25,12 @@ public:
return ((CustomSX1262 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE;
}
bool isReceivingPacket() override {
bool isReceivingPacket() override {
return ((CustomSX1262 *)_radio)->isReceiving();
}
bool isChipBusy() override {
return ((CustomSX1262 *)_radio)->isChipBusy();
}
float getCurrentRSSI() override {
return ((CustomSX1262 *)_radio)->getRSSI(false);
}
@@ -42,6 +46,52 @@ public:
((CustomSX1262 *)_radio)->sleep(false);
}
bool supportsRxPowerSaving() const override { return true; }
protected:
int startReceiveMode() override {
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
return _radio->startReceive();
}
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;
}
MESH_DEBUG_PRINTLN("CustomSX1262Wrapper: error: startReceiveDutyCycle(%d), falling back to continuous RX", err);
return _radio->startReceive();
}
void stopReceiveDutyCycle() override {
_radio->standby(); // also wakes the chip if it is in the sleep window
((CustomSX1262 *)_radio)->stopRTC();
_rx_ps_armed = false;
}
bool radioDeepInit() override {
// std_init() re-runs RadioLib begin(), which starts with a hardware reset
// via NRST - the only way out of a hard-locked chip (BUSY stuck high).
return ((CustomSX1262 *)_radio)->std_init();
}
public:
void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); }
bool setRxBoostedGainMode(bool en) override {
+221 -23
View File
@@ -11,6 +11,11 @@
#define NUM_NOISE_FLOOR_SAMPLES 64
#define SAMPLING_THRESHOLD 14
// periodic noise-floor calibration windows (RX duty-cycle powersaving only)
#define NF_CALIB_INTERVAL_MS 60000UL // at least once a minute
#define NF_CALIB_TIMEOUT_MS 5000UL // give up on the batch (busy channel)
#define NF_CALIB_SETTLE_MS 20UL // frontend/AGC settle after RX entry
static volatile uint8_t state = STATE_IDLE;
// this function is called when a complete packet
@@ -48,6 +53,8 @@ uint32_t RadioLibWrapper::getRngSeed() {
}
void RadioLibWrapper::setTxPower(int8_t dbm) {
_cur_dbm = dbm;
_dbm_valid = true;
_radio->setOutputPower(dbm);
}
@@ -84,9 +91,132 @@ void RadioLibWrapper::resetAGC() {
_floor_sample_sum = 0;
}
void RadioLibWrapper::rxPsWatchdogCheck() {
// don't interfere mid-transmit or with a completed-but-unread packet
// (a pending DIO1 event is itself proof the radio is alive; recvRaw() will
// re-arm and re-base the watchdog)
if ((state & STATE_INT_READY) != 0 || (state & ~STATE_INT_READY) == STATE_TX_WAIT) {
_wd_observe_until = 0;
return;
}
unsigned long now = millis();
bool tripped = false;
if (_rx_ps_armed && state == STATE_RX && _wd_stuck_thresh > 0) {
bool busy = isChipBusy();
if (busy != _wd_last_busy) {
// the sleep/listen wave is present -> radio healthy
_wd_last_busy = busy;
_wd_last_transition = now;
_wd_stage = 0;
_wd_strikes = 0;
_wd_observe_until = 0;
} else if (_wd_observe_until != 0) {
// active observation window in progress (MCU kept awake via
// isWatchdogObserving()); a healthy chip must toggle BUSY within it
if ((long)(now - _wd_observe_until) >= 0) {
_wd_observe_until = 0;
if (!busy && isReceivingPacket()) {
// BUSY held low by an ongoing reception (extended RX) - alive
_wd_last_transition = now;
_wd_strikes = 0;
} else if (++_wd_strikes >= 2) {
_wd_strikes = 0;
tripped = true;
} else {
_wd_last_transition = now; // full threshold before the next window
}
}
} else if (now - _wd_last_transition > _wd_stuck_thresh) {
// no proof of life for too long: actively watch one full cycle
_wd_observe_until = now + _wd_observe_ms;
if (_wd_observe_until == 0) _wd_observe_until = 1; // 0 means "off"
}
} else {
_wd_observe_until = 0;
}
if (_startrx_fails >= 3) tripped = true; // can't even re-arm receive mode
if (!tripped) return;
_wd_last_transition = now; // grace period before the next escalation
_startrx_fails = 0;
_wd_observe_until = 0;
if (_wd_stage == 0) {
_wd_stage = 1;
n_wd_soft++;
MESH_DEBUG_PRINTLN("RadioLibWrapper: watchdog: RX duty-cycle stuck, soft re-arm");
state = STATE_IDLE; // next recvRaw() re-arms receive mode
} else {
_wd_stage = 2;
n_wd_hard++;
MESH_DEBUG_PRINTLN("RadioLibWrapper: watchdog: still stuck, hard radio reset");
if (radioDeepInit()) {
_rx_ps_armed = false; // chip is factory-fresh after NRST
_radio->setPacketReceivedAction(setFlag);
if (_params_valid) setParams(_cur_freq, _cur_bw, _cur_sf, _cur_cr);
if (_dbm_valid) _radio->setOutputPower(_cur_dbm);
}
state = STATE_IDLE; // re-arm (rx powersaving settings are kept in members)
}
}
// Periodic noise-floor calibration, active only with RX duty-cycle powersaving:
// a duty-cycled receiver can't be sampled reliably (the frontend is off in the
// sleep windows and settling right after each wake), so at least once a minute
// the receive mode is dropped to plain continuous RX, a fresh sample batch is
// collected exactly like the non-powersaving path does, and the duty cycle is
// re-armed. The published average stays in _noise_floor as usual.
void RadioLibWrapper::noiseFloorCalibCheck() {
unsigned long now = millis();
if (_nf_calib_active) {
if (!_rx_ps_enabled || (long)(now - _nf_calib_deadline) >= 0) {
// powersaving turned off mid-window, or the batch couldn't complete
// (busy channel / stuck filter) - keep the previous floor
endNoiseFloorCalib(now);
}
} else if (_rx_ps_enabled && _rx_ps_armed && state == STATE_RX
&& now - _nf_last_calib >= NF_CALIB_INTERVAL_MS
&& !isReceivingPacket()) {
// never interrupt an ongoing reception to calibrate (a TX in flight is
// already excluded by state == STATE_RX); retries next loop iteration
_nf_calib_active = true;
_nf_calib_deadline = now + NF_CALIB_TIMEOUT_MS;
_nf_sample_from = now + NF_CALIB_SETTLE_MS;
_num_floor_samples = 0; // start a fresh batch for this window
_floor_sample_sum = 0;
state = STATE_IDLE; // recvRaw() re-arms; startReceiveMode() sees the
// active flag and starts continuous RX, not duty-cycle
}
}
void RadioLibWrapper::endNoiseFloorCalib(unsigned long now) {
_nf_calib_active = false;
_nf_last_calib = now;
// force a receive re-arm back into duty-cycle mode, but don't clobber a
// completed-but-unread packet or an in-flight TX (recvRaw()/onSendFinished()
// will re-arm right after those anyway; same guard style as setRxPowerSaving)
if ((state & STATE_INT_READY) == 0 && (state & ~STATE_INT_READY) != STATE_TX_WAIT) {
state = STATE_IDLE;
}
}
void RadioLibWrapper::loop() {
if (_rx_ps_enabled) {
rxPsWatchdogCheck();
}
noiseFloorCalibCheck();
if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) {
if (!isReceivingPacket()) {
// Noise floor is only sampled outside RX duty-cycle mode: continuously in
// plain RX (powersaving off), or inside the periodic calibration window
// (powersaving on), skipping the first moments after RX entry there while
// the frontend/AGC settles (unsettled GetRssiInst reads ~-127 dBm garbage).
if (!_rx_ps_armed
&& !(_nf_calib_active && (long)(millis() - _nf_sample_from) < 0)
&& !isReceivingPacket()) {
int rssi = getCurrentRSSI();
if (rssi < _noise_floor + SAMPLING_THRESHOLD) { // only consider samples below current floor + sampling THRESHOLD
_num_floor_samples++;
@@ -101,50 +231,110 @@ void RadioLibWrapper::loop() {
_floor_sample_sum = 0;
MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor);
if (_nf_calib_active) {
endNoiseFloorCalib(millis()); // fresh floor published - back to duty cycle
}
}
}
void RadioLibWrapper::startRecv() {
int err = _radio->startReceive();
int err = startReceiveMode();
if (err == RADIOLIB_ERR_NONE) {
state = STATE_RX;
_startrx_fails = 0;
if (_rx_ps_armed) {
// (re)base the duty-cycle watchdog on the freshly armed cycle
_wd_last_busy = isChipBusy();
_wd_last_transition = millis();
// Longest legitimate silence on the BUSY pin: one full cycle, plus the
// extended RX after a (possibly false) preamble detect (2*rx + sleep),
// 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;
_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;
// active observation window must cover one full duty cycle
_wd_observe_ms = rx_ms + sleep_ms + 50;
if (_wd_observe_ms > 1500) _wd_observe_ms = 1500;
}
} else {
MESH_DEBUG_PRINTLN("RadioLibWrapper: error: startReceive(%d)", err);
if (_startrx_fails < 255) _startrx_fails++;
MESH_DEBUG_PRINTLN("RadioLibWrapper: error: startReceiveMode(%d)", err);
}
}
int RadioLibWrapper::startReceiveMode() {
return _radio->startReceive();
}
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();
_rx_ps_armed = false;
}
bool RadioLibWrapper::isPacketReady() {
if (!_rx_ps_armed) return true; // continuous RX: DIO1 only fires for RxDone/TxDone here
// In duty-cycle RX the DIO1 interrupt also fires for RX timeout (false
// preamble detect) and header errors. GetRxBufferStatus still reports the
// *previous* packet's length then, so reading the buffer would re-deliver
// stale bytes as a ghost packet. Only read when the radio reports RxDone.
// (checkIrq errors are treated as ready, falling back to old behaviour.)
return _radio->checkIrq(RADIOLIB_IRQ_RX_DONE) != 0;
}
bool RadioLibWrapper::isInRecvMode() const {
return (state & ~STATE_INT_READY) == STATE_RX;
}
bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) {
if (enabled && !supportsRxPowerSaving()) {
return false;
}
_rx_ps_enabled = enabled;
_rx_ps_rx_us = rx_us;
_rx_ps_sleep_us = sleep_us;
// Force the next recvRaw() to arm the requested RX mode, but don't clobber a
// completed-but-unread packet (STATE_INT_READY): recvRaw() will consume it and
// then re-arm with the new mode. Also leave an in-flight TX alone. (Same
// non-atomic guard style as resetAGC().)
if ((state & STATE_INT_READY) == 0 && (state & ~STATE_INT_READY) != STATE_TX_WAIT) {
state = STATE_IDLE;
}
return true;
}
int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) {
int len = 0;
if (state & STATE_INT_READY) {
last_radio_interrupt_millis = millis(); // ISR fired → radio hardware is alive
len = _radio->getPacketLength();
if (len > 0) {
if (len > sz) { len = sz; }
int err = _radio->readData(bytes, len);
if (err != RADIOLIB_ERR_NONE) {
MESH_DEBUG_PRINTLN("RadioLibWrapper: error: readData(%d)", err);
len = 0;
n_recv_errors++;
} else {
// Serial.print(" readData() -> "); Serial.println(len);
n_recv++;
last_recv_millis = millis();
if (isPacketReady()) {
len = _radio->getPacketLength();
if (len > 0) {
if (len > sz) { len = sz; }
int err = _radio->readData(bytes, len);
if (err != RADIOLIB_ERR_NONE) {
MESH_DEBUG_PRINTLN("RadioLibWrapper: error: readData(%d)", err);
len = 0;
n_recv_errors++;
} else {
// Serial.print(" readData() -> "); Serial.println(len);
n_recv++;
last_recv_millis = millis();
}
}
}
state = STATE_IDLE; // need another startReceive()
}
if (state != STATE_RX) {
int err = _radio->startReceive();
if (err == RADIOLIB_ERR_NONE) {
state = STATE_RX;
} else {
MESH_DEBUG_PRINTLN("RadioLibWrapper: error: startReceive(%d)", err);
}
startRecv();
}
return len;
}
@@ -154,6 +344,11 @@ uint32_t RadioLibWrapper::getEstAirtimeFor(int len_bytes) {
}
bool RadioLibWrapper::startSendRaw(const uint8_t* bytes, int len) {
if (_rx_ps_armed) {
// stop the duty-cycle sequencer before SetTx, otherwise its next RTC
// event can fire mid-transmission and abort the TX
stopReceiveDutyCycle();
}
_board->onBeforeTransmit();
int err = _radio->startTransmit((uint8_t *) bytes, len);
if (err == RADIOLIB_ERR_NONE) {
@@ -186,8 +381,11 @@ int16_t RadioLibWrapper::performChannelScan() {
}
bool RadioLibWrapper::isChannelActive() {
// int.thresh: RSSI-based interference detection (relative to noise floor)
if (_threshold != 0 && getCurrentRSSI() > _noise_floor + _threshold) return true;
// int.thresh: RSSI-based interference detection (relative to noise floor).
// In RX duty-cycle mode only checked while the chip is in a listen window
// (during the sleep window the frontend is off and the read would stall).
if (_threshold != 0 && !(_rx_ps_armed && isChipBusy())
&& getCurrentRSSI() > _noise_floor + _threshold) return true;
// cad: hardware channel activity detection
if (_cad_enabled) {
+82 -6
View File
@@ -3,6 +3,13 @@
#include <Mesh.h>
#include <RadioLib.h>
// Fallback RX powersaving timings, only used until setRxPowerSaving() is called
// (begin() always applies the persisted values). The authoritative defaults live
// in CommonCLI.h as RX_POWERSAVING_DEFAULT_RX_US / _SLEEP_US and are delivered
// via NodePrefs; keep these mirrored.
#define RX_PS_FALLBACK_RX_US 65625UL
#define RX_PS_FALLBACK_SLEEP_US 60000UL
class RadioLibWrapper : public mesh::Radio {
protected:
PhysicalLayer* _radio;
@@ -15,20 +22,79 @@ protected:
unsigned long last_recv_millis;
unsigned long last_radio_interrupt_millis; // updated on any ISR event, even CRC errors
uint8_t _preamble_sf;
bool _rx_ps_enabled;
bool _rx_ps_armed; // radio is currently in RX duty-cycle mode
uint32_t _rx_ps_rx_us;
uint32_t _rx_ps_sleep_us;
// RX duty-cycle watchdog: a healthy duty cycle shows a square wave on the
// BUSY pin (high in the sleep window / TCXO warmup, low while listening).
// If the wave stops, the chip fell out of the cycle without an IRQ.
// Passive sampling only works while the main loop spins; on MCUs that light
// sleep between wakeups the watchdog instead opens an "active observation"
// window (isWatchdogObserving() keeps the MCU awake) spanning one full radio
// cycle - a healthy chip must toggle BUSY within it.
bool _wd_last_busy;
uint8_t _wd_stage; // 0 = healthy, 1 = soft re-arm done, 2 = hard reset done
uint8_t _wd_strikes; // consecutive failed observation windows
uint8_t _startrx_fails; // consecutive startReceiveMode() failures
unsigned long _wd_last_transition; // millis of last BUSY level change (proof of life)
unsigned long _wd_stuck_thresh; // ms without proof of life before observing
unsigned long _wd_observe_until; // 0 = not observing, else millis deadline
uint32_t _wd_observe_ms; // observation window: one full cycle + margin
uint32_t n_wd_soft, n_wd_hard;
// last applied radio settings, reapplied after a hard radio reset
float _cur_freq, _cur_bw;
uint8_t _cur_sf, _cur_cr;
int8_t _cur_dbm;
bool _params_valid, _dbm_valid;
// Periodic noise-floor calibration (only while RX duty-cycle powersaving is
// armed): a duty-cycled receiver can't be sampled reliably, so at least once
// a minute the wrapper drops to plain continuous RX, collects a fresh sample
// batch exactly like the non-powersaving path does, publishes the average
// into _noise_floor and re-arms the duty cycle.
bool _nf_calib_active;
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)
void idle() override;
void startRecv() override;
void rxPsWatchdogCheck();
void noiseFloorCalibCheck();
void endNoiseFloorCalib(unsigned long now);
void cacheParams(float freq, float bw, uint8_t sf, uint8_t cr) {
_cur_freq = freq; _cur_bw = bw; _cur_sf = sf; _cur_cr = cr; _params_valid = true;
}
virtual int startReceiveMode();
virtual void stopReceiveDutyCycle();
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
virtual bool isChipBusy() { return false; }
// full radio recovery: hardware reset (NRST) + re-init to boot defaults;
// returns false if unsupported. Caller reapplies cached runtime params.
virtual bool radioDeepInit() { return false; }
float packetScoreInt(float snr, int sf, int packet_len);
virtual bool isReceivingPacket() =0;
virtual void doResetAGC();
public:
RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _preamble_sf(0) {
n_recv = n_sent = n_recv_errors = 0;
last_recv_millis = 0;
last_radio_interrupt_millis = 0;
_cad_enabled = false;
}
RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board)
: _radio(&radio), _board(&board), _preamble_sf(0), _rx_ps_enabled(false), _rx_ps_armed(false),
_rx_ps_rx_us(RX_PS_FALLBACK_RX_US), _rx_ps_sleep_us(RX_PS_FALLBACK_SLEEP_US),
_wd_last_busy(false), _wd_stage(0), _wd_strikes(0), _startrx_fails(0), _wd_last_transition(0),
_wd_stuck_thresh(0), _wd_observe_until(0), _wd_observe_ms(0),
_params_valid(false), _dbm_valid(false),
_nf_calib_active(false), _nf_last_calib(0), _nf_calib_deadline(0), _nf_sample_from(0)
{
n_recv = n_sent = n_recv_errors = n_wd_soft = n_wd_hard = 0;
last_recv_millis = 0;
last_radio_interrupt_millis = 0;
_cad_enabled = false;
}
void begin() override;
virtual void powerOff() { _radio->sleep(); }
@@ -38,6 +104,7 @@ public:
bool isSendComplete() override;
void onSendFinished() override;
bool isInRecvMode() const override;
bool setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) override;
bool isChannelActive();
bool isReceiving() override {
@@ -68,6 +135,15 @@ public:
uint32_t getPacketsRecv() const { return n_recv; }
uint32_t getPacketsRecvErrors() const override { return n_recv_errors; }
uint32_t getPacketsSent() const { return n_sent; }
uint32_t getRxPsWatchdogSoftCount() const { return n_wd_soft; }
uint32_t getRxPsWatchdogHardCount() const { return n_wd_hard; }
// true while the watchdog is actively watching for BUSY transitions; used by
// the app's hasPendingWork() to keep the MCU out of light sleep for the window
bool isWatchdogObserving() const { return _wd_observe_until != 0; }
// true while a periodic noise-floor calibration window is in progress; the
// app's hasPendingWork() must keep the MCU awake so the sample batch and the
// return to duty-cycle complete promptly
bool isCalibratingNoiseFloor() const { return _nf_calib_active; }
void resetStats() { n_recv = n_sent = n_recv_errors = 0; }
uint8_t getRadioState() const override;