diff --git a/src/helpers/CommonRadioPrefs.cpp b/src/helpers/CommonRadioPrefs.cpp index 41b179932..8fce22701 100644 --- a/src/helpers/CommonRadioPrefs.cpp +++ b/src/helpers/CommonRadioPrefs.cpp @@ -3,6 +3,34 @@ #include "Utils.h" #include "target.h" +// PowerSaving variant +#include "radiolib/RXPowerSavingCLI.h" + +static RxPowerSavingControl* rxpsControl() { +#ifdef WRAPPER_CLASS + return &radio_driver; +#else + return nullptr; +#endif +} + +// PowerSaving variant +bool CommonRadioPrefs::applyRxPowerSaving(uint8_t sf, float bw) { + _active_sf = sf; + _active_bw = bw; + RxPowerSavingConfig* config = getRxPowerSavingConfig(); + if (!config) return false; + // Temporary radio parameters must not replace the persisted automatic periods. + RxPowerSavingConfig active = *config; + return applyRxPowerSavingConfig(&active, sf, bw, rxpsControl()); +} + +// PowerSaving variant +bool CommonRadioPrefs::isRxPowerSavingCalibrationActive() const { + const auto* control = rxpsControl(); + return control && control->isRxPowerSavingCalibrationActive(); +} + bool CommonRadioPrefs::getByKey(const char* key, char* value, size_t max_len) { if (strcmp(key, "fem_rxgain") == 0) { snprintf(value, max_len, "%d", (uint32_t)getFEMRxGain()); @@ -29,6 +57,30 @@ bool CommonRadioPrefs::setByKey(const char* key, const char* value) { } bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + // PowerSaving variant + if (strncmp(command, "get radio.rxps", 14) == 0 && + (command[14] == 0 || command[14] == ' ')) { + const char* suffix = command + 14; + while (*suffix == ' ') ++suffix; + if (*suffix) return false; + auto* config = getRxPowerSavingConfig(); + if (!config) { strcpy(reply, "Error: unsupported"); return true; } + const uint8_t sf = _active_sf ? _active_sf : getSpreadFactor(); + const float bw = _active_sf ? _active_bw : getBandwidth(); + RxPowerSavingConfig active = *config; + normalizeRxPowerSavingConfig(&active, sf, bw, + rxPowerSavingCaptureCost(rxpsControl()), rxPowerSavingTransition(rxpsControl())); + RXPowerSavingCLI::get(&active, rxpsControl(), sf, bw, reply, 160); + return true; + } + if (strncmp(command, "set radio.rxps ", 15) == 0) { + auto* config = getRxPowerSavingConfig(); + if (!config) { strcpy(reply, "Error: unsupported"); return true; } + if (RXPowerSavingCLI::set(command + 15, + _active_sf ? _active_sf : getSpreadFactor(), _active_sf ? _active_bw : getBandwidth(), + config, rxpsControl(), reply, 160)) markDirty(); + return true; + } if (strcmp(command, "get radio") == 0) { char freq[16], bw[16]; strcpy(freq, StrHelper::ftoa(getFreq())); @@ -50,7 +102,16 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest setCodingRate(cr); setFreq(freq); setBandwidth(bw); - strcpy(reply, "OK - reboot to apply"); + + // PowerSaving variant + auto* rxps = getRxPowerSavingConfig(); + const bool timing_ok = !rxps || !rxps->enabled || normalizeRxPowerSavingConfig( + rxps, sf, bw, rxPowerSavingCaptureCost(rxpsControl()), rxPowerSavingTransition(rxpsControl())); + if (!timing_ok && rxpsControl()) { + rxpsControl()->setRxPowerSaving(false, rxps->rx_us, rxps->sleep_us); + rxpsControl()->setRxPowerSavingConfigError(true); + } + strcpy(reply, timing_ok ? "OK - reboot to apply" : "OK - reboot to apply; RXPS continuous: invalid timing"); } else { strcpy(reply, "Error, invalid radio params"); } @@ -110,7 +171,9 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest } if (strcmp(command, "get cad") == 0) { - sprintf(reply, "> %s", isCadEnabled() ? "on" : "off"); + // PowerSaving variant + sprintf(reply, "> %s; channel busy: %lu", isCadEnabled() ? "on" : "off", + (unsigned long)radio_driver.getCADBusyCount()); return true; } if (memcmp(command, "set cad ", 8) == 0) { diff --git a/src/helpers/radiolib/CustomLR1110.h b/src/helpers/radiolib/CustomLR1110.h index f0fde4354..4426d56ac 100644 --- a/src/helpers/radiolib/CustomLR1110.h +++ b/src/helpers/radiolib/CustomLR1110.h @@ -3,6 +3,9 @@ #include #include "MeshCore.h" +// PowerSaving variant +#include "RXPowerSaving.h" + class CustomLR1110 : public LR1110 { uint32_t _preambleMillis = 66; uint32_t _maxPayloadMillis = 3934; @@ -13,16 +16,30 @@ class CustomLR1110 : public LR1110 { public: CustomLR1110(Module *mod) : LR1110(mod) { } + // PowerSaving variant + // See CustomSX1262::begin - same reasoning, LR11x0 flags and calibration. int16_t begin(float freq = 434.0, float bw = 125.0, uint8_t sf = 9, uint8_t cr = 7, uint8_t syncWord = RADIOLIB_LR11X0_LORA_SYNC_WORD_PRIVATE, int8_t power = 10, uint16_t preambleLength = 8, float tcxoVoltage = 1.6) { int16_t state = LR1110::begin(freq, bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage); + // PowerSaving variant + if (state == RADIOLIB_ERR_NONE) state = applyMeshCoreTcxoDelay(); // RadioLib begin() defaults to LDO; use the LR1110 DC/DC regulator. if (state == RADIOLIB_ERR_NONE) state = setRegulatorDCDC(); return state; } + // PowerSaving variant + int16_t applyMeshCoreTcxoDelay() { + if (tcxoVoltage <= 0.0f) return RADIOLIB_ERR_NONE; + int16_t state = setTCXO(tcxoVoltage, MC_TCXO_DELAY_US); + RADIOLIB_ASSERT(state); + state = calibrate(0x3F); + delay(50); + return state; + } + size_t getPacketLength(bool update) override { size_t len = LR1110::getPacketLength(update); if (len == 0 && getIrqStatus() & RADIOLIB_LR11X0_IRQ_HEADER_ERR) { @@ -38,6 +55,69 @@ class CustomLR1110 : public LR1110 { float getFreqMHz() const { return freqMHz; } + // PowerSaving variant + // The sleep -> RX transition RadioLib subtracts from a duty-cycle sleep. + // Protected in the RadioLib base, so the RXPS layer reads it through here. + uint32_t getTcxoDelay() const { return tcxoDelay; } + + // Restores the LF clock configuration RadioLib's begin() installs. Arming + // the duty cycle switches it to RC + BUSY-release (required by Semtech for + // SetRxDutyCycle); without this the change would outlive RXPS being turned + // off, and it also alters what the BUSY pin means to isChipBusy(). + int16_t restoreLfClock() { + return configLfClock( + RADIOLIB_LR11X0_LF_BUSY_RELEASE_DISABLED | RADIOLIB_LR11X0_LF_CLK_XOSC); + } + + // effectiveRxPeriod reports the RX window actually programmed, which may be + // stretched above the requested value to satisfy the extended-period rule. + int16_t startReceiveDutyCycle(uint32_t rxPeriod, uint32_t sleepPeriod, + RadioLibIrqFlags_t irqFlags = RADIOLIB_IRQ_RX_DEFAULT_FLAGS, + RadioLibIrqFlags_t irqMask = RADIOLIB_IRQ_RX_DEFAULT_MASK, + uint32_t* effectiveRxPeriod = nullptr) { + // Keep the conversion local until RadioLib uses 64-bit multiplication; + // its pinned implementation overflows for longer microsecond periods. + uint32_t symbolPeriod = (uint32_t)(((1000.0f * (float)(1UL << this->spreadingFactor)) / + this->bandwidthKhz) + 0.999f); + uint32_t transitionTime = this->tcxoDelay + 1000; + if (sleepPeriod <= transitionTime) return RADIOLIB_ERR_INVALID_SLEEP_PERIOD; + uint32_t programmedSleepPeriod = sleepPeriod - transitionTime; + + uint64_t requiredExtendedPeriod = + ((uint64_t)this->preambleLengthLoRa + 11ULL) * symbolPeriod + 1000ULL; + uint64_t extendedPeriod = 2ULL * rxPeriod + programmedSleepPeriod; + if (extendedPeriod < requiredExtendedPeriod) { + rxPeriod = (uint32_t)((requiredExtendedPeriod - programmedSleepPeriod + 1ULL) / 2ULL); + } + if (effectiveRxPeriod) *effectiveRxPeriod = rxPeriod; + + uint32_t rxPeriodRaw = (uint32_t)(((uint64_t)rxPeriod * 32768UL) / 1000000UL); + uint32_t sleepPeriodRaw = + (uint32_t)(((uint64_t)programmedSleepPeriod * 32768UL) / 1000000UL); + if ((rxPeriodRaw & 0xFF000000) || rxPeriodRaw == 0) return RADIOLIB_ERR_INVALID_RX_PERIOD; + if ((sleepPeriodRaw & 0xFF000000) || sleepPeriodRaw == 0) { + return RADIOLIB_ERR_INVALID_SLEEP_PERIOD; + } + + int16_t state = standby(RADIOLIB_LR11X0_STANDBY_RC); + RADIOLIB_ASSERT(state); + // Semtech requires the RC standby/RTC setup before SetRxDutyCycle. + state = configLfClock(RADIOLIB_LR11X0_LF_CLK_RC | RADIOLIB_LR11X0_LF_BUSY_RELEASE_ENABLED); + RADIOLIB_ASSERT(state); + RadioModeConfig_t cfg = { + .receive = { + .timeout = RADIOLIB_LR11X0_RX_TIMEOUT_INF, + .irqFlags = irqFlags, + .irqMask = irqMask, + .len = 0, + } + }; + + state = stageMode(RADIOLIB_RADIO_MODE_RX, &cfg); + RADIOLIB_ASSERT(state); + return setRxDutyCycle(rxPeriodRaw, sleepPeriodRaw, RADIOLIB_LR11X0_RX_DUTY_CYCLE_MODE_RX); + } + int16_t setRxBoostedGainMode(bool en) { _rx_boosted = en; return LR1110::setRxBoostedGainMode(en); @@ -50,6 +130,12 @@ class CustomLR1110 : public LR1110 { return LR1110::startReceive(RADIOLIB_LR11X0_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); } + // PowerSaving variant + bool isChipBusy() { + uint32_t busy = this->mod->getGpio(); + return busy != RADIOLIB_NC && this->mod->hal->digitalRead(busy); + } + bool isReceiving() { uint32_t irq = getIrqStatus(); bool preamble = irq & RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED; // bit 4 diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index e7aaeb937..5d82e7b1d 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -6,9 +6,28 @@ class CustomLR1110Wrapper : public RadioLibWrapper { public: + // PowerSaving variant + // Measured on ThinkNode M3 and T1000-E: this family needs roughly one symbol + // more margin than SX126x before a duty-cycled receiver latches a preamble. + float rxPowerSavingCaptureCostSymbols() const override { + return RX_POWERSAVING_CAPTURE_COST_SYMBOLS_LR11X0; + } + + // PowerSaving variant + // Same reasoning as the SX126x wrapper. This family never showed either + // duty-cycle failure on the bench, which is unsurprising: its driver has + // always enforced an extended-period rule equivalent to the timer guard the + // SX126x side only just gained. + uint32_t rxPowerSavingTransitionUs() const override { + return ((CustomLR1110 *)_radio)->getTcxoDelay() + 1000; + } + CustomLR1110Wrapper(CustomLR1110& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + // PowerSaving variant + prepareForRadioConfig(); + ((CustomLR1110 *)_radio)->setFrequency(freq); ((CustomLR1110 *)_radio)->setSpreadingFactor(sf); ((CustomLR1110 *)_radio)->setBandwidth(bw); @@ -20,8 +39,18 @@ public: } bool isReceivingPacket() override { + // PowerSaving variant + // While duty-cycling, BUSY marks the sleep phase: probing IRQ status over + // SPI there could wake the chip and break the cycle. Only skip the probe + // in that case - outside RXPS this must stay a real channel check, since + // Dispatcher::checkSend() relies on it to avoid transmitting over others. + if (_rx_ps_armed && ((CustomLR1110 *)_radio)->isChipBusy()) return false; return ((CustomLR1110 *)_radio)->isReceiving(); } + // PowerSaving variant + bool isChipBusy() override { + return ((CustomLR1110 *)_radio)->isChipBusy(); + } float getCurrentRSSI() override { float rssi = -110; ((CustomLR1110 *)_radio)->getRssiInst(&rssi); @@ -38,12 +67,40 @@ public: _radio->setPreambleLength(preambleLengthForSF(getSpreadingFactor())); // overcomes weird issues with small and big pkts } - float getLastRSSI() const override { return ((CustomLR1110 *)_radio)->getRSSI(); } - float getLastSNR() const override { return ((CustomLR1110 *)_radio)->getSNR(); } + bool supportsRxPowerSaving() const override { return true; } + +protected: + // PowerSaving variant + int16_t startContinuousReceive() override { + // Also covers a stopDutyCycleHardware() whose clock restore failed. + int16_t err = _radio->standby(); + if (err == RADIOLIB_ERR_NONE) err = ((CustomLR1110*)_radio)->restoreLfClock(); + return err == RADIOLIB_ERR_NONE ? _radio->startReceive() : err; + } + + // PowerSaving variant + int16_t armDutyCycle(RadioLibIrqFlags_t irq_flags, RadioLibIrqFlags_t irq_mask, + uint32_t* eff_rx_us, uint32_t* eff_sleep_us) override { + *eff_sleep_us = _rx_ps_sleep_us; // only the RX window can get clamped + return ((CustomLR1110 *)_radio)->startReceiveDutyCycle( + _rx_ps_rx_us, _rx_ps_sleep_us, irq_flags, irq_mask, eff_rx_us); + } + + // PowerSaving variant + int16_t stopDutyCycleHardware() override { + const int16_t state = _radio->standby(); + const int16_t clock = ((CustomLR1110 *)_radio)->restoreLfClock(); + return state != RADIOLIB_ERR_NONE ? state : clock; + } + +public: uint8_t getSpreadingFactor() const override { return ((CustomLR1110 *)_radio)->getSpreadingFactor(); } bool setRxBoostedGainMode(bool en) override { + // PowerSaving variant + prepareForRadioConfig(); + return ((CustomLR1110 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } bool getRxBoostedGainMode() const override { diff --git a/src/helpers/radiolib/CustomLR2021.h b/src/helpers/radiolib/CustomLR2021.h index 0255b93cf..7e5aa2e9e 100644 --- a/src/helpers/radiolib/CustomLR2021.h +++ b/src/helpers/radiolib/CustomLR2021.h @@ -68,6 +68,12 @@ class CustomLR2021 : public LR2021 { float getFreqMHz() const { return freqMHz; } + // PowerSaving variant + int16_t setRxBoostedGainMode(uint8_t level) { + const int16_t status = LR2021::setRxBoostedGainMode(level); + if (status == RADIOLIB_ERR_NONE) _rx_boosted = level != 0; + return status; + } bool getRxBoostedGainMode() const { return _rx_boosted; } int16_t startReceive() override { diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index b4ee6c97a..46bb7fc87 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -3,6 +3,9 @@ #include #include "MeshCore.h" +// PowerSaving variant +#include "RXPowerSaving.h" // MC_TCXO_DELAY_US + class CustomSX1262 : public SX1262 { uint32_t _preambleMillis = 66; uint32_t _maxPayloadMillis = 3934; @@ -10,8 +13,43 @@ class CustomSX1262 : public SX1262 { bool _headerSeen = false; public: + // PowerSaving variant + // The sleep -> RX transition RadioLib subtracts from a duty-cycle sleep. + // Protected in the RadioLib base, so the RXPS layer reads it through here. + uint32_t getTcxoDelay() const { return tcxoDelay; } + CustomSX1262(Module *mod) : SX1262(mod) { } + // PowerSaving variant + // RadioLib's begin() installs its own 5000 us TCXO startup delay and + // setTCXO() does not recalibrate, so MeshCore's value has to be written + // afterwards and the RC calibrations redone against the new clock-gating + // window. Overriding begin() puts that in one place: every variant reaches + // the radio through this class, whether it calls std_init() or begin() + // directly, so no target.cpp or variant .ini has to know about it. + int16_t begin(float freq = 434.0, float bw = 125.0, uint8_t sf = 9, uint8_t cr = 7, + uint8_t syncWord = RADIOLIB_SX126X_SYNC_WORD_PRIVATE, int8_t power = 10, + uint16_t preambleLength = 8, float tcxoVoltage = 1.6, + bool useRegulatorLDO = false) { + int16_t state = SX1262::begin(freq, bw, sf, cr, syncWord, power, preambleLength, + tcxoVoltage, useRegulatorLDO); + if (state == RADIOLIB_ERR_NONE) state = applyMeshCoreTcxoDelay(); + return state; + } + + // PowerSaving variant + // Read tcxoVoltage back from the chip object rather than from the argument: + // begin() zeroes it when it falls back to an XTAL, and re-asserting a TCXO + // supply on DIO3 for a board that has none would be worse than a long delay. + int16_t applyMeshCoreTcxoDelay() { + if (tcxoVoltage <= 0.0f) return RADIOLIB_ERR_NONE; + int16_t state = setTCXO(tcxoVoltage, MC_TCXO_DELAY_US); + RADIOLIB_ASSERT(state); + state = calibrate(RADIOLIB_SX126X_CALIBRATE_ALL); + delay(50); + return state; + } + #ifdef RP2040_PLATFORM bool std_init(SPIClassRP2040* spi = NULL) #else @@ -105,6 +143,26 @@ class CustomSX1262 : public SX1262 { return SX1262::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); } + // PowerSaving variant + int16_t startReceiveDutyCycle(uint32_t rxPeriod, uint32_t sleepPeriod, + RadioLibIrqFlags_t irqFlags = RADIOLIB_IRQ_RX_DEFAULT_FLAGS, + RadioLibIrqFlags_t irqMask = RADIOLIB_IRQ_RX_DEFAULT_MASK) { + int16_t state = SX1262::startReceiveDutyCycle(rxPeriod, sleepPeriod, irqFlags, irqMask); + if (state == RADIOLIB_ERR_NONE) { + // RadioLib stages RX through standby, which leaves a host-controlled + // RXEN switch in IDLE. Set it back to RX for the whole duty-cycle; + // boards without an external RXEN table are unaffected. + this->mod->setRfSwitchState(Module::MODE_RX); + } + return state; + } + + // PowerSaving variant + bool isChipBusy() { + uint32_t busy = this->mod->getGpio(); + return busy != RADIOLIB_NC && this->mod->hal->digitalRead(busy); + } + bool isReceiving() { uint32_t irq = getIrqFlags(); bool preamble = irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; // bit 2 @@ -148,6 +206,20 @@ class CustomSX1262 : public SX1262 { return false; } + // PowerSaving variant + int16_t stopRTC() { + uint8_t rtcStop = 0x00; + int16_t state = writeRegister(RADIOLIB_SX126X_REG_RTC_CTRL, &rtcStop, 1); + RADIOLIB_ASSERT(state); + + uint8_t rtcEvent = 0; + state = readRegister(RADIOLIB_SX126X_REG_EVENT_MASK, &rtcEvent, 1); + RADIOLIB_ASSERT(state); + + rtcEvent |= 0x02; + return writeRegister(RADIOLIB_SX126X_REG_EVENT_MASK, &rtcEvent, 1); + } + void setPreambleMillis(uint32_t preambleMillis) { _preambleMillis = preambleMillis; MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index be3144716..7a6d8b855 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -10,9 +10,21 @@ class CustomSX1262Wrapper : public RadioLibWrapper { public: + // PowerSaving variant + // Ask the radio rather than assume RadioLib's 5000 us default: startReceive- + // DutyCycle subtracts tcxoDelay + 1000 from the sleep and the register + // underflows below that, so the floor is a property of this board's TCXO. A + // board without one can duty cycle with a far shorter sleep. The 100 us on + // top clears the 15.625 us register tick that must remain. + uint32_t rxPowerSavingTransitionUs() const override { + return ((CustomSX1262 *)_radio)->getTcxoDelay() + 1000; + } + CustomSX1262Wrapper(CustomSX1262& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + // PowerSaving variant + prepareForRadioConfig(); ((CustomSX1262 *)_radio)->setFrequency(freq); ((CustomSX1262 *)_radio)->setSpreadingFactor(sf); ((CustomSX1262 *)_radio)->setBandwidth(bw); @@ -23,14 +35,22 @@ public: ((CustomSX1262 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); } - bool isReceivingPacket() override { + bool isReceivingPacket() override { + // PowerSaving variant + // While duty-cycling, BUSY marks the sleep phase: probing IRQ flags over + // SPI there could wake the chip and break the cycle. Only skip the probe + // in that case - outside RXPS this must stay a real channel check, since + // Dispatcher::checkSend() relies on it to avoid transmitting over others. + if (_rx_ps_armed && ((CustomSX1262 *)_radio)->isChipBusy()) return false; return ((CustomSX1262 *)_radio)->isReceiving(); } + // PowerSaving variant + bool isChipBusy() override { + return ((CustomSX1262 *)_radio)->isChipBusy(); + } float getCurrentRSSI() override { return ((CustomSX1262 *)_radio)->getRSSI(false); } - float getLastRSSI() const override { return ((CustomSX1262 *)_radio)->getRSSI(); } - float getLastSNR() const override { return ((CustomSX1262 *)_radio)->getSNR(); } float packetScore(float snr, int packet_len) override { int sf = ((CustomSX1262 *)_radio)->spreadingFactor; @@ -38,10 +58,37 @@ public: } uint8_t getSpreadingFactor() const override { return ((CustomSX1262 *)_radio)->spreadingFactor; } virtual void powerOff() override { + // PowerSaving variant + if (_rx_ps_armed) stopReceiveDutyCycle(); ((CustomSX1262 *)_radio)->sleep(false); } + // PowerSaving variant + bool supportsRxPowerSaving() const override { return true; } + +protected: + // PowerSaving variant + int16_t armDutyCycle(RadioLibIrqFlags_t irq_flags, RadioLibIrqFlags_t irq_mask, + uint32_t* eff_rx_us, uint32_t* eff_sleep_us) override { + // RadioLib programs the SX126x with exactly what we ask for (it only + // subtracts the wake-up transition from the sleep period internally). + *eff_rx_us = _rx_ps_rx_us; + *eff_sleep_us = _rx_ps_sleep_us; + return ((CustomSX1262 *)_radio)->startReceiveDutyCycle( + _rx_ps_rx_us, _rx_ps_sleep_us, irq_flags, irq_mask); + } + + // PowerSaving variant + int16_t stopDutyCycleHardware() override { + int16_t standby_err = _radio->standby(); + int16_t rtc_err = ((CustomSX1262 *)_radio)->stopRTC(); + return standby_err != RADIOLIB_ERR_NONE ? standby_err : rtc_err; + } + +public: bool setRxBoostedGainMode(bool en) override { + // PowerSaving variant + prepareForRadioConfig(); return ((CustomSX1262 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } bool getRxBoostedGainMode() const override { diff --git a/src/helpers/radiolib/RXPowerSaving.cpp b/src/helpers/radiolib/RXPowerSaving.cpp new file mode 100644 index 000000000..6a357390d --- /dev/null +++ b/src/helpers/radiolib/RXPowerSaving.cpp @@ -0,0 +1,245 @@ +#include "RXPowerSaving.h" +#include + +static uint32_t ceilRxPowerSavingValue(float value) { + uint32_t rounded = (uint32_t)value; + return value > (float)rounded ? rounded + 1 : rounded; +} + +// Both radios program their duty cycle in 15.625 us ticks and truncate the +// microsecond value on the way in, so a period of 10013 us is really 10000 us +// on air while the CLI happily reports 10013. Snapping to the nearest tick and +// returning the smallest microsecond value that still lands on it makes the +// reported periods the ones the hardware runs - which matters here, because the +// register value turned out to be what a duty-cycle defect keys on, not the +// microseconds we asked for. +static uint32_t rxPowerSavingTickToUs(uint64_t tick) { + if (tick == 0) tick = 1; + return (uint32_t)((tick * 125 + 7) / 8); // ceil(tick * 15.625) +} + +static uint32_t snapRxPowerSavingToTick(uint32_t us) { + return rxPowerSavingTickToUs(((uint64_t)us * 8 + 62) / 125); // nearest +} + +// Rounding the listen window to the *nearest* tick can shave a few microseconds +// off, and the timer guard below has no room to give: it must hold with the +// register values the radio actually runs. So that one rounds up. +static uint32_t snapRxPowerSavingUpToTick(uint32_t us) { + return rxPowerSavingTickToUs(((uint64_t)us * 8 + 124) / 125); // ceil +} + +bool isValidRxPowerSavingPeriod(uint32_t us) { + return us >= RX_POWERSAVING_MIN_PERIOD_US && us <= RX_POWERSAVING_MAX_PERIOD_US; +} + +uint8_t rxPowerSavingPreambleForSF(uint8_t sf) { + return sf <= 8 ? 32 : 16; +} + +bool isRxPowerSavingNumeric(const char* value) { + if (value == nullptr || *value == 0) return false; + while (*value) { + if (*value < '0' || *value > '9') return false; + value++; + } + return true; +} + +float rxPowerSavingCaptureCost(const RxPowerSavingControl* control) { + return control != nullptr ? control->rxPowerSavingCaptureCostSymbols() + : RX_POWERSAVING_CAPTURE_COST_SYMBOLS; +} + +uint32_t rxPowerSavingTransition(const RxPowerSavingControl* control) { + return control != nullptr ? control->rxPowerSavingTransitionUs() + : RX_POWERSAVING_TRANSITION_US; +} + +// rxPeriod register values measured to break SetRxDutyCycle on SX1262: the +// radio latches the preamble and then never validates a header, losing 35% of +// packets at SF6 and all of them at SF7. Both were reproduced on two boards +// with an LR1110 witnessing 100% of the same transmissions, and both bands are +// exactly one tick wide - 639 and 641 are lossless. +// +// This list is certainly incomplete: about thirty values were sampled out of +// the thousands a profile can generate. Levels 1-10 do not need it because the +// timer guard makes any value safe; it exists only for the unguarded maximum, +// where it is the one protection left. +static const uint32_t RX_POWERSAVING_BAD_RX_TICKS[] = {320, 640}; + +static uint32_t avoidBadRxPowerSavingTick(uint32_t rx_us) { + const uint32_t tick = (rx_us * 8) / 125; + for (uint32_t bad : RX_POWERSAVING_BAD_RX_TICKS) { + // One tick longer costs 15.625 us of listening and steps clear of the band. + if (tick == bad) return (uint32_t)((((uint64_t)tick + 1) * 125 + 7) / 8); + } + return rx_us; +} + +bool calcRxPowerSavingLevel(uint8_t level, uint8_t sf, float bw, uint8_t preamble, + uint32_t* rx_us, uint32_t* sleep_us, + float capture_cost_symbols, uint32_t transition_us) { + if (rx_us == nullptr || sleep_us == nullptr || level < 1 || + level > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL || sf < 5 || sf > 12 || + !isfinite(bw) || bw <= 0.0f || !isfinite(capture_cost_symbols) || + capture_cost_symbols <= 0 || capture_cost_symbols >= preamble || transition_us >= RX_POWERSAVING_MAX_PERIOD_US || (preamble != 16 && preamble != 32)) { + return false; + } + const bool unguarded = isRxPowerSavingUnguardedLevel(level); + + const float symbol_us = (1000.0f * (float)(1UL << sf)) / bw; + if (!isfinite(symbol_us) || symbol_us * 64.0f > (float)(UINT32_MAX / 8)) return false; + // Worst case a preamble starts the instant an RX window closes, so the part + // that survives into the next window is `preamble - sleep`. The absolute + // limit is therefore reached when that equals the chip's capture cost; the + // unguarded levels are pinned to geometries measured at or past that point, + // which is why they still need it. + const float sleep_edge_symbols = (float)preamble - capture_cost_symbols; + + float sleep_symbols; + float rx_symbols = 0.0f; // only used by the unguarded levels + if (unguarded) { + // Kept on the old interpolation because that is the geometry the bench + // measured: overdrive is the top of the profile, and `riskyWorkingMax` is + // the point past it where delivery started to fall. + float amount = 1.0f; + if (isRxPowerSavingRiskyWorkingMaxLevel(level)) { + const float virtual_level = preamble == 16 ? RX_POWERSAVING_RISKY_WORKING_MAX_VIRTUAL_P16 + : RX_POWERSAVING_RISKY_WORKING_MAX_VIRTUAL_P32; + amount = (virtual_level - 1.0f) / 9.0f; + } + const float rx_start_symbols = preamble == 16 ? 12.0f : 16.0f; + const float sleep_start_symbols = preamble == 16 ? 2.0f : 15.0f; + rx_symbols = rx_start_symbols + amount * (8.0f - rx_start_symbols); + sleep_symbols = sleep_start_symbols + amount * (sleep_edge_symbols - sleep_start_symbols); + } else { + // Guarded levels state their catch directly, so the sleep is just what is + // left of the preamble - no capture cost involved. The pad makes the node + // catch a fifth of a symbol more than promised, which is what keeps the + // generated register pairs off the defective ones. + const float catch_symbols = rxPowerSavingLevelCatch(level, preamble); + // A level that undertakes to catch less than this radio needs to latch is + // not usable: say so rather than return a geometry that cannot work. + if (catch_symbols < capture_cost_symbols) return false; + sleep_symbols = (float)preamble - catch_symbols - RX_POWERSAVING_MARGIN_PAD_SYMBOLS; + if (sleep_symbols < 0.0f) sleep_symbols = 0.0f; + } + + *sleep_us = (uint32_t)(sleep_symbols * symbol_us); + + // Below the hardware floor the duty cycle cannot be armed at all: the driver + // returns -708 and the wrapper quietly runs continuous RX, so the node keeps + // reporting power saving while spending full RX current. Just above that the + // SX1262 arms with ERR_NONE and then detects no preambles at all, which is + // worse. Measured floor: 6050-6100 us dead, 6200 us and up lossless. + const uint32_t min_sleep_us = transition_us + RX_POWERSAVING_MIN_SLEEP_MARGIN_US; + if (*sleep_us < min_sleep_us) { + if ((float)min_sleep_us > sleep_edge_symbols * symbol_us) { + // Even zero margin would not fit above the floor: no level of this + // profile can duty cycle at this SF/BW. Say so instead of returning + // something that will silently fall back. + return false; + } + // The floor eats into the margin. Levels below it collapse onto one point; + // the reply still reports the periods, so the loss of margin is visible. + *sleep_us = min_sleep_us; + } + *sleep_us = transition_us + snapRxPowerSavingToTick(*sleep_us - transition_us); + + // SetRxDutyCycle timer guard, straight out of the SX1261/2 datasheet: on + // preamble detection the radio restarts its timer with 2*rxPeriod + + // sleepPeriod (register values), and the packet dies if the header does not + // arrive inside it. The datasheet states the requirement as + // Tpreamble + Theader <= 2 * rxPeriod + sleepPeriod + // Measured: when it holds, no period value causes trouble. When it is broken + // the SX1262 usually gets away with it - but not always, and the exceptions + // are a single register tick wide. rxPeriod 640 (exactly 10.000 ms) loses + // 60-65% of packets at SF6 and everything at SF7, on two different boards, + // while 639 and 641 are lossless; rxPeriod 320 behaves the same way. Nothing + // in the register value predicts which ticks misbehave, so satisfy the + // condition instead of dodging the values. The listen window is therefore not + // a free parameter on the guarded scale - it is whatever the condition needs. + const float sync_symbols = + sf <= 6 ? RX_POWERSAVING_SYNC_SYMBOLS_LOW_SF : RX_POWERSAVING_SYNC_SYMBOLS; + const float need_us = + ((float)preamble + sync_symbols + RX_POWERSAVING_HEADER_SYMBOLS + + RX_POWERSAVING_TIMER_SLACK_SYMBOLS) * symbol_us; + // Work in the radio's own units: the driver truncates (sleep - transition) to + // 15.625 us ticks, so the microsecond difference overstates the programmed + // sleep by up to one tick - enough to leave the condition short on every + // level of a profile. + const uint32_t sleep_ticks = ((*sleep_us - transition_us) * 8) / 125; + const float programmed_sleep_us = (float)sleep_ticks * RX_POWERSAVING_TICK_US; + + if (unguarded) { + *rx_us = ceilRxPowerSavingValue(rx_symbols * symbol_us); + } else { + const float min_rx_us = (need_us - programmed_sleep_us) / 2.0f; + *rx_us = ceilRxPowerSavingValue(min_rx_us > 0.0f ? min_rx_us : symbol_us); + } + + *rx_us = snapRxPowerSavingUpToTick(*rx_us); + if (unguarded) *rx_us = avoidBadRxPowerSavingTick(*rx_us); + return true; +} + +void ensureRxPowerSavingDefaults(uint32_t* rx_us, uint32_t* sleep_us) { + if (!isValidRxPowerSavingPeriod(*rx_us)) *rx_us = RX_POWERSAVING_DEFAULT_RX_US; + if (!isValidRxPowerSavingPeriod(*sleep_us)) *sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US; +} + +bool recalcRxPowerSavingFromLevel(uint8_t level, uint8_t sf, float bw, uint8_t preamble, + uint32_t* rx_us, uint32_t* sleep_us, + float capture_cost_symbols, uint32_t transition_us) { + if (level < 1 || level > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL) return false; + if (preamble == 0) preamble = rxPowerSavingPreambleForSF(sf); + + uint32_t calculated_rx_us = 0; + uint32_t calculated_sleep_us = 0; + if (!calcRxPowerSavingLevel(level, sf, bw, preamble, &calculated_rx_us, &calculated_sleep_us, + capture_cost_symbols, transition_us) || + !isValidRxPowerSavingPeriod(calculated_rx_us) || + !isValidRxPowerSavingPeriod(calculated_sleep_us)) { + return false; + } + + *rx_us = calculated_rx_us; + *sleep_us = calculated_sleep_us; + return true; +} + +bool normalizeRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw, + float capture_cost_symbols, uint32_t transition_us) { + if (config == nullptr || sf < 5 || sf > 12 || !isfinite(bw) || bw <= 0) return false; + RxPowerSavingConfig proposed = *config; + proposed.enabled = proposed.enabled ? 1 : 0; + if (proposed.level > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL) { + proposed.level = RX_POWERSAVING_BALANCED_LEVEL; + } + if (proposed.preamble != 0 && proposed.preamble != 16 && proposed.preamble != 32) { + proposed.preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + } + if (proposed.level == 0) { + if (!isValidRxPowerSavingPeriod(proposed.rx_us) || + !isValidRxPowerSavingPeriod(proposed.sleep_us)) return false; + } else if (!recalcRxPowerSavingFromLevel(proposed.level, sf, bw, proposed.preamble, + &proposed.rx_us, &proposed.sleep_us, + capture_cost_symbols, transition_us)) { + return false; + } + *config = proposed; + return true; +} + +bool applyRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw, + RxPowerSavingControl* control) { + if (config == nullptr) return false; + const bool valid = !config->enabled || normalizeRxPowerSavingConfig( + config, sf, bw, rxPowerSavingCaptureCost(control), rxPowerSavingTransition(control)); + if (control == nullptr) return !config->enabled; + const bool accepted = control->setRxPowerSaving( + config->enabled && valid, config->rx_us, config->sleep_us); + control->setRxPowerSavingConfigError(!valid); + return valid && accepted; +} diff --git a/src/helpers/radiolib/RXPowerSaving.h b/src/helpers/radiolib/RXPowerSaving.h new file mode 100644 index 000000000..5f52560e4 --- /dev/null +++ b/src/helpers/radiolib/RXPowerSaving.h @@ -0,0 +1,275 @@ +#pragma once + +#include + +static constexpr uint32_t RX_POWERSAVING_DEFAULT_RX_US = 65625UL; +static constexpr uint32_t RX_POWERSAVING_DEFAULT_SLEEP_US = 60000UL; +static constexpr uint32_t RX_POWERSAVING_MIN_PERIOD_US = 1000UL; +static constexpr uint32_t RX_POWERSAVING_MAX_PERIOD_US = 30000000UL; + +// The level scale is a catch dial, in symbols of the sender's preamble. +// +// Level N means: "at least this many symbols of the sender's preamble land +// inside an open RX window." That is the quantity the radio actually needs - +// a preamble is latched once enough of it has been heard - so the level states +// the requirement directly instead of stating a distance from an edge. +// +// The arithmetic follows from that in one step. Worst case, a preamble starts +// the instant an RX window closes, so what survives into the next window is +// `preamble - sleep`. Requiring that to be at least the level's catch gives +// +// sleep = preamble - catch +// +// and the chip's capture cost never enters the geometry. It becomes a +// validation instead: a level is usable when its catch is at least the cost. +// The floor of the scale is 8 symbols precisely because that is what the +// slower of the two families needs (LR11x0 8, SX126x 6), so **every level +// means the same geometry on both radios** - which the previous margin-based +// scale could not do, because the cost sat inside its formula. +// +// The two profiles get their own ladders. On a 16-symbol preamble there are +// only 8 symbols to spend between the floor and the whole preamble, so the +// steps are single symbols; on a 32-symbol preamble there are 24, so the low +// levels can take much larger strides where reception is guaranteed anyway. +static constexpr uint8_t RX_POWERSAVING_GUARDED_LEVELS = 8; +static constexpr float RX_POWERSAVING_LEVEL_CATCH_P16[RX_POWERSAVING_GUARDED_LEVELS] = { + 15.0f, 14.0f, 13.0f, 12.0f, 11.0f, 10.0f, 9.0f, 8.0f +}; +static constexpr float RX_POWERSAVING_LEVEL_CATCH_P32[RX_POWERSAVING_GUARDED_LEVELS] = { + 24.0f, 20.0f, 16.0f, 14.0f, 12.0f, 10.0f, 9.0f, 8.0f +}; + +// Smallest catch any guarded level asks for, and the reason it is 8: the +// LR11x0 needs 8 symbols to latch where the SX126x needs 6. +static constexpr float RX_POWERSAVING_MIN_CATCH_SYMBOLS = 8.0f; + +// On top of the ladder, every guarded level sleeps this much less than the +// arithmetic demands - so it catches 0.2 symbols more than its level promises. +// The reason is not capture safety: it is that specific register pairs the +// calculation lands on are defective (preamble latched, header never +// validated) in the same way isolated rxPeriod ticks 320 and 640 are. Nudging +// every generated geometry off those points cost 0.05 mA and took the worst +// measured cell from 3.33% packet loss to 0.11% across the whole ladder. +// Levels 9 and 10 are exempt - their geometries are pinned to bench +// measurements and shifting them would invalidate that data. +static constexpr float RX_POWERSAVING_MARGIN_PAD_SYMBOLS = 0.2f; + +// Past the guarded scale sit two settings that break the datasheet's +// SetRxDutyCycle timer condition on purpose. Overdrive is lossless and 4-12 +// percentage points cheaper in duty cycle; `riskyWorkingMax` is the measured +// working edge and drops ~2% of packets by design. +static constexpr uint8_t RX_POWERSAVING_OVERDRIVE_LEVEL = 9; +static constexpr uint8_t RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL = 10; +static constexpr float RX_POWERSAVING_RISKY_WORKING_MAX_VIRTUAL_P32 = 11.0f; +static constexpr float RX_POWERSAVING_RISKY_WORKING_MAX_VIRTUAL_P16 = 10.25f; + +// Top of the guarded scale: catches the ladder floor of 8 symbols (8.2 with +// the pad), the cheapest setting that still meets the datasheet condition and +// still works on both radio families. The CLI calls it `max`. +static constexpr uint8_t RX_POWERSAVING_MAX_LEVEL = RX_POWERSAVING_GUARDED_LEVELS; + +static constexpr uint8_t RX_POWERSAVING_CONSERVATIVE_LEVEL = 3; // catches 13 (P16) / 16 (P32) +static constexpr uint8_t RX_POWERSAVING_BALANCED_LEVEL = 6; // catches 10 (P16) / 10 (P32) +static constexpr uint8_t RX_POWERSAVING_PROFILE_PREAMBLE = 16; +static constexpr uint8_t RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES = 3; + +// Symbols of a sender's preamble consumed before a packet can be latched: +// ~2 to latch the preamble plus ~2 lost to RX-window startup, measured on +// SX1262 at SF8/BW62.5. Capture needs sleep <= sender_preamble - this. +// Note this is well below the 8 symbols RadioLib quotes (SX126x.h) - that +// figure is the datasheet's margin for *reliable* latching, not the floor. +// +// Chip dependent, so it belongs with the driver rather than with each board: +// a wrapper overrides rxPowerSavingCaptureCostSymbols() and every variant using +// that radio picks it up. LR1110 needs about one symbol more - on the bench it +// held 300/300 at a margin of 7.78 symbols and started dropping packets at +// 6.89, for both profiles, where SX1262 is clean down to 6. +static constexpr float RX_POWERSAVING_CAPTURE_COST_SYMBOLS = 6.0f; +static constexpr float RX_POWERSAVING_CAPTURE_COST_SYMBOLS_LR11X0 = 8.0f; + +// TCXO startup delay handed to SetDIO3AsTCXOCtrl (SX126x DS 13.3.6) / +// SetTcxoMode (LR11x0 UM 6.3.2). RadioLib defaults it to 5000 us and nothing +// overrode it, so every board paid 5 ms on every duty-cycle wake whatever +// crystal was fitted. Measured lowest working value on five modules: T096 400, +// Tracker V2 300, Waveshare 150, ThinkNode M3 200, T1000-E 200 us - so 1600 +// keeps 4x margin over the worst of them. +// +// One knob for the whole tree, in `arduino_base`, because it is a property of +// the parts MeshCore is built with rather than of any one board. A variant that +// needs its own value defines it in its own build_flags and the later -D wins. +#ifndef MC_TCXO_DELAY_US + #define MC_TCXO_DELAY_US 1600 +#endif + +// Sleep -> RX transition: RadioLib subtracts tcxoDelay + 1000 us from the +// requested sleep before writing the register, and the hardware spends that +// long saving context, restarting the XTAL and locking the PLL. Everything +// below derives from it, so a board that knows its real TCXO delay overrides +// one method and both the sleep floor and the timer guard follow. +static constexpr uint32_t RX_POWERSAVING_TRANSITION_US = MC_TCXO_DELAY_US + 1000; + +// Slack the timer guard leaves on top of the datasheet condition, in symbols. +// +// Closing the condition exactly is not enough. The calculator lands on register +// pairs that satisfy it by a hair and still lose packets: rx=2476/sleep=2455 +// ticks clears it by 22 us at 0.12 symbol and drops 2-6% on every SX1262 tried, +// while moving any one component clears the fault. It is the same family as the +// isolated bad ticks 320 and 640 - the condition does not predict which pairs +// misbehave, so the answer is to stop generating geometries that sit on its +// edge. One whole symbol of slack is what the bench evidence supports. +#ifndef MC_RXPS_TIMER_SLACK_SYMBOLS + #define MC_RXPS_TIMER_SLACK_SYMBOLS 1.0f +#endif +static constexpr float RX_POWERSAVING_TIMER_SLACK_SYMBOLS = MC_RXPS_TIMER_SLACK_SYMBOLS; + +// Both radio families program their duty cycle in these units, and truncate the +// microsecond value on the way in. Everything the timer guard compares has to +// be expressed in whole ticks or it comes out short by a fraction of one. +static constexpr float RX_POWERSAVING_TICK_US = 15.625f; + +// Margin above the transition before a duty cycle actually works; see below. +static constexpr uint32_t RX_POWERSAVING_MIN_SLEEP_MARGIN_US = 250; + +// Symbols between the end of the sender's preamble and the end of the header: +// the LoRa sync word plus the 8-symbol explicit header. SF5/SF6 use a longer +// sync word than SF7 and above. Used by the SetRxDutyCycle timer guard. +static constexpr float RX_POWERSAVING_SYNC_SYMBOLS_LOW_SF = 6.25f; +static constexpr float RX_POWERSAVING_SYNC_SYMBOLS = 4.25f; +static constexpr float RX_POWERSAVING_HEADER_SYMBOLS = 8.0f; + +// Shortest sleep that actually produces a working duty cycle. +// +// RadioLib subtracts tcxoDelay + 1000 us from the requested sleep before +// writing the register. At or below that the register underflows and arming +// fails with RADIOLIB_ERR_INVALID_SLEEP_PERIOD (-708), after which the wrapper +// falls back to continuous RX while the config still says power saving is on. +// Measured on SX1262: the P16 profile fails to arm on levels 1-5 at SF6 and on +// levels 1-2 at SF7, exactly where that bound predicts. +// +// But arming successfully is NOT the boundary. Just above it the SX1262 accepts +// the command, returns ERR_NONE, and then detects **zero** preambles - a +// silently deaf receiver, which is worse than the honest -708. Measured twice, +// on two different SF and RX windows: sleep 6050-6100 us is dead, 6200 us and +// everything above it is lossless (40/40 at every value up to 21617 us). The +// chip needs roughly 200 us of *programmed* sleep, i.e. ~12 register ticks, so +// this leaves 250 us of margin on top of the transition time. +// +// The default assumes the 5000 us TCXO delay RadioLib installs; a wrapper that +// can read its radio overrides this with the board's real value. +static constexpr uint32_t RX_POWERSAVING_MIN_SLEEP_US = + RX_POWERSAVING_TRANSITION_US + RX_POWERSAVING_MIN_SLEEP_MARGIN_US; + +class RxPowerSavingArmRetryState { + uint8_t _consecutive_failures = 0; + +public: + bool canAttempt() const { + return _consecutive_failures < RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES; + } + void recordSuccess() { _consecutive_failures = 0; } + void recordFailure() { + if (_consecutive_failures < RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES) { + _consecutive_failures++; + } + } + void reset() { _consecutive_failures = 0; } + uint8_t consecutiveFailures() const { return _consecutive_failures; } +}; + +struct RxPowerSavingConfig { + uint8_t enabled = 0; + uint32_t rx_us = RX_POWERSAVING_DEFAULT_RX_US; + uint32_t sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US; + uint8_t level = RX_POWERSAVING_BALANCED_LEVEL; + uint8_t preamble = RX_POWERSAVING_PROFILE_PREAMBLE; +}; + +struct RxPowerSavingStatus { + bool supported = false; + bool armed = false; + int16_t last_error = 0; + uint32_t arm_failures = 0; + // Periods the radio actually runs with. A driver may clamp the requested + // values to satisfy a hardware constraint, so these can differ from the + // configured rx_us/sleep_us. Zero means "nothing armed yet". + uint32_t effective_rx_us = 0; + uint32_t effective_sleep_us = 0; +}; + +class RxPowerSavingControl { +public: + virtual ~RxPowerSavingControl() = default; + + virtual bool setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) { + (void)rx_us; + (void)sleep_us; + return !enabled; + } + virtual RxPowerSavingStatus getRxPowerSavingStatus() const { return {}; } + virtual void setRxPowerSavingConfigError(bool invalid) { (void)invalid; } + virtual bool isRxPowerSavingCalibrationActive() const { return false; } + + // Symbols of the sender's preamble consumed before a packet can be latched. + // Overridden per radio family; see RX_POWERSAVING_CAPTURE_COST_SYMBOLS. + virtual float rxPowerSavingCaptureCostSymbols() const { + return RX_POWERSAVING_CAPTURE_COST_SYMBOLS; + } + + // Sleep -> RX transition of this radio, in microseconds. The sleep floor and + // the SetRxDutyCycle timer guard are both derived from it, so a wrapper that + // can read its own tcxoDelay only needs to override this one method. + virtual uint32_t rxPowerSavingTransitionUs() const { + return RX_POWERSAVING_TRANSITION_US; + } +}; + +// Margin this level asks for, in symbols. Only meaningful on the guarded scale. +// Symbols of the sender's preamble this level undertakes to catch. The two +// profiles have separate ladders, so the profile has to be passed in; anything +// other than 16 or 32 is treated as the 16-symbol profile, matching the +// validation in calcRxPowerSavingLevel(). +inline float rxPowerSavingLevelCatch(uint8_t level, uint8_t preamble) { + if (level < 1 || level > RX_POWERSAVING_GUARDED_LEVELS) return 0.0f; + return preamble == 32 ? RX_POWERSAVING_LEVEL_CATCH_P32[level - 1] + : RX_POWERSAVING_LEVEL_CATCH_P16[level - 1]; +} + +inline bool isRxPowerSavingOverdriveLevel(uint8_t level) { + return level == RX_POWERSAVING_OVERDRIVE_LEVEL; +} + +inline bool isRxPowerSavingRiskyWorkingMaxLevel(uint8_t level) { + return level == RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL; +} + +// Both levels past the guarded scale run outside the datasheet's timer +// condition. Worth surfacing wherever a level is reported. +inline bool isRxPowerSavingUnguardedLevel(uint8_t level) { + return level >= RX_POWERSAVING_OVERDRIVE_LEVEL; +} + +bool isValidRxPowerSavingPeriod(uint32_t us); +uint8_t rxPowerSavingPreambleForSF(uint8_t sf); +bool isRxPowerSavingNumeric(const char* value); +// capture_cost_symbols defaults to the SX126x figure so callers without a radio +// handy still compile; anything holding a RxPowerSavingControl should pass +// rxPowerSavingCaptureCost(control) instead. +float rxPowerSavingCaptureCost(const RxPowerSavingControl* control); +uint32_t rxPowerSavingTransition(const RxPowerSavingControl* control); +bool calcRxPowerSavingLevel(uint8_t level, uint8_t sf, float bw, uint8_t preamble, + uint32_t* rx_us, uint32_t* sleep_us, + float capture_cost_symbols = RX_POWERSAVING_CAPTURE_COST_SYMBOLS, + uint32_t transition_us = RX_POWERSAVING_TRANSITION_US); +void ensureRxPowerSavingDefaults(uint32_t* rx_us, uint32_t* sleep_us); +bool recalcRxPowerSavingFromLevel(uint8_t level, uint8_t sf, float bw, uint8_t preamble, + uint32_t* rx_us, uint32_t* sleep_us, + float capture_cost_symbols = RX_POWERSAVING_CAPTURE_COST_SYMBOLS, + uint32_t transition_us = RX_POWERSAVING_TRANSITION_US); +bool normalizeRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw, + float capture_cost_symbols = RX_POWERSAVING_CAPTURE_COST_SYMBOLS, + uint32_t transition_us = RX_POWERSAVING_TRANSITION_US); + +// The requested configuration survives a timing failure; only the radio falls back. +bool applyRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw, + RxPowerSavingControl* control); +static constexpr int16_t RX_POWERSAVING_ERR_TIMING = -10001; diff --git a/src/helpers/radiolib/RXPowerSavingCLI.cpp b/src/helpers/radiolib/RXPowerSavingCLI.cpp new file mode 100644 index 000000000..d9b9b7511 --- /dev/null +++ b/src/helpers/radiolib/RXPowerSavingCLI.cpp @@ -0,0 +1,268 @@ +#include "RXPowerSavingCLI.h" + +#include +#include +#include +#include +#include + +// Suffix printed after the level so an unguarded profile is never mistaken for +// an ordinary one, in both the `set` echo and the `get` report. +static const char* rxPowerSavingLevelTag(uint8_t level) { + if (isRxPowerSavingRiskyWorkingMaxLevel(level)) return "(riskyWorkingMax)"; + if (isRxPowerSavingOverdriveLevel(level)) return "(overdrive)"; + return ""; +} + +static bool parseRxPowerSavingUint32(const char* value, uint32_t* parsed) { + if (parsed == nullptr || !isRxPowerSavingNumeric(value)) return false; + + uint32_t result = 0; + while (*value) { + uint32_t digit = (uint32_t)(*value++ - '0'); + if (result > (UINT32_MAX - digit) / 10U) return false; + result = result * 10U + digit; + } + *parsed = result; + return true; +} + +bool RXPowerSavingCLI::set(const char* value, uint8_t sf, float bw, + RxPowerSavingConfig* config, RxPowerSavingControl* control, + char* reply, size_t reply_size) { + if (value == nullptr || config == nullptr || reply == nullptr || reply_size == 0) return false; + + RxPowerSavingConfig proposed = *config; + uint8_t level = 0; + uint8_t preamble = rxPowerSavingPreambleForSF(sf); + bool level_requested = false; + bool preamble_overridden = false; + + ensureRxPowerSavingDefaults(&proposed.rx_us, &proposed.sleep_us); + + if (strcmp(value, "off") == 0) { + proposed.enabled = 0; + } else if (strcmp(value, "on") == 0 || strcmp(value, "conservative") == 0) { + proposed.enabled = 1; + level = RX_POWERSAVING_CONSERVATIVE_LEVEL; + preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + level_requested = true; + preamble_overridden = true; + } else if (strcmp(value, "max") == 0) { + // Top of the guarded scale. Like every named preset it assumes the worst + // case a mixed network can present - a sender using a 16-symbol preamble - + // so at SF<=8 it is markedly less economical than `level 10`, which follows + // the SF and picks the 32-symbol profile. `max preamble 32` gets that back. + proposed.enabled = 1; + level = RX_POWERSAVING_MAX_LEVEL; + preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + level_requested = true; + preamble_overridden = true; + } else if (strcmp(value, "overdrive") == 0) { + // One step past the guarded scale: measured lossless, outside the + // datasheet's timer condition. Same worst-case preamble as the presets above. + proposed.enabled = 1; + level = RX_POWERSAVING_OVERDRIVE_LEVEL; + preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + level_requested = true; + preamble_overridden = true; + } else if (strcmp(value, "riskyWorkingMax") == 0) { + // This one is not lossless, so the risk is explicit in its name. Same + // worst-case preamble as the presets above. + proposed.enabled = 1; + level = RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL; + preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + level_requested = true; + preamble_overridden = true; + } else if (strcmp(value, "balanced") == 0) { + proposed.enabled = 1; + level = RX_POWERSAVING_BALANCED_LEVEL; + preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + level_requested = true; + preamble_overridden = true; + } else { + char input[128]; + if (strlen(value) >= sizeof(input)) { + snprintf(reply, reply_size, + "ERROR: use off|on|conservative|balanced|max|overdrive|riskyWorkingMax|level <1-10>| "); + return false; + } + strcpy(input, value); + + const char* parts[4]; + int count = mesh::Utils::parseTextParts(input, parts, 4, ' '); + uint32_t first = 0; + uint32_t second = 0; + if (count == 1 && parseRxPowerSavingUint32(parts[0], &first)) { + if (first < 1 || first > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL) { + snprintf(reply, reply_size, "ERROR: level range is 1-%u (or max|overdrive|riskyWorkingMax); preamble is 16 or 32", + RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL); + return false; + } + level = (uint8_t)first; + level_requested = true; + proposed.enabled = 1; + } else if (count >= 2 && strcmp(parts[1], "preamble") == 0 && + (strcmp(parts[0], "max") == 0 || strcmp(parts[0], "overdrive") == 0 || + strcmp(parts[0], "riskyWorkingMax") == 0)) { + if (count != 3 || !parseRxPowerSavingUint32(parts[2], &first) || + (first != 16 && first != 32)) { + snprintf(reply, reply_size, "ERROR: use %s preamble <16|32>", parts[0]); + return false; + } + level = strcmp(parts[0], "overdrive") == 0 ? RX_POWERSAVING_OVERDRIVE_LEVEL + : strcmp(parts[0], "max") == 0 ? RX_POWERSAVING_MAX_LEVEL + : RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL; + preamble = (uint8_t)first; + level_requested = true; + preamble_overridden = true; + proposed.enabled = 1; + } else if (count == 2 && strcmp(parts[0], "level") == 0 && + parseRxPowerSavingUint32(parts[1], &first)) { + if (first < 1 || first > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL) { + snprintf(reply, reply_size, "ERROR: level range is 1-%u (or max|overdrive|riskyWorkingMax); preamble is 16 or 32", + RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL); + return false; + } + level = (uint8_t)first; + level_requested = true; + proposed.enabled = 1; + } else if (count == 4 && strcmp(parts[0], "level") == 0 && + parseRxPowerSavingUint32(parts[1], &first) && + strcmp(parts[2], "preamble") == 0 && + parseRxPowerSavingUint32(parts[3], &second)) { + if (first < 1 || first > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL || + (second != 16 && second != 32)) { + snprintf(reply, reply_size, "ERROR: level range is 1-%u (or max|overdrive|riskyWorkingMax); preamble is 16 or 32", + RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL); + return false; + } + level = (uint8_t)first; + preamble = (uint8_t)second; + level_requested = true; + preamble_overridden = true; + proposed.enabled = 1; + } else if (count == 2 && parseRxPowerSavingUint32(parts[0], &first) && + parseRxPowerSavingUint32(parts[1], &second)) { + proposed.rx_us = first; + proposed.sleep_us = second; + proposed.enabled = 1; + } else { + snprintf(reply, reply_size, + "ERROR: use off|on|conservative|balanced|max|overdrive|riskyWorkingMax|level <1-10>| "); + return false; + } + } + + if (proposed.enabled && (sf < 5 || sf > 12 || !isfinite(bw) || bw <= 0)) { + if (control) { + control->setRxPowerSaving(false, proposed.rx_us, proposed.sleep_us); + control->setRxPowerSavingConfigError(true); + } + snprintf(reply, reply_size, "ERROR: invalid SF/BW for RXPS"); + return false; + } + if (level_requested && + !recalcRxPowerSavingFromLevel(level, sf, bw, preamble, + &proposed.rx_us, &proposed.sleep_us, + rxPowerSavingCaptureCost(control), + rxPowerSavingTransition(control))) { + // Level and preamble were validated above, so the only way to get here is + // that no sleep exists which both arms the hardware and still lets a + // preamble of this length be caught - i.e. the symbol is too short for RXPS + // at this SF/BW. Saying "level range is 1-10" would send the user hunting + // for the wrong thing. + if (control) { + control->setRxPowerSaving(false, proposed.rx_us, proposed.sleep_us); + control->setRxPowerSavingConfigError(true); + } + snprintf(reply, reply_size, "ERROR: RXPS does not fit this SF/BW with preamble %lu", + (unsigned long)preamble); + return false; + } + if (!isValidRxPowerSavingPeriod(proposed.rx_us) || + !isValidRxPowerSavingPeriod(proposed.sleep_us)) { + snprintf(reply, reply_size, "ERROR: range is %lu-%lu us", + (unsigned long)RX_POWERSAVING_MIN_PERIOD_US, + (unsigned long)RX_POWERSAVING_MAX_PERIOD_US); + return false; + } + + bool applied = control != nullptr + ? control->setRxPowerSaving(proposed.enabled != 0, proposed.rx_us, proposed.sleep_us) + : proposed.enabled == 0; + if (!applied) { + snprintf(reply, reply_size, "ERROR: RX powersaving unsupported"); + return false; + } + + if (control) control->setRxPowerSavingConfigError(false); + + if (level_requested) { + proposed.level = level; + proposed.preamble = preamble_overridden ? preamble : 0; + } else if (strcmp(value, "off") != 0) { + proposed.level = 0; + proposed.preamble = 0; + } + + *config = proposed; + snprintf(reply, reply_size, "OK - %s,level=%lu%s,preamble=%lu,rx=%lu,sleep=%lu", + config->enabled ? "on" : "off", (unsigned long)config->level, + rxPowerSavingLevelTag(config->level), + (unsigned long)config->preamble, (unsigned long)config->rx_us, + (unsigned long)config->sleep_us); + return true; +} + +void RXPowerSavingCLI::get(const RxPowerSavingConfig* config, + const RxPowerSavingControl* control, + uint8_t sf, float bw, char* reply, size_t reply_size) { + if (config == nullptr || reply == nullptr || reply_size == 0) return; + + RxPowerSavingStatus status = control != nullptr + ? control->getRxPowerSavingStatus() + : RxPowerSavingStatus{}; + // Preamble symbols the node actually catches. Not simply the number the level + // asked for: the sleep floor can shorten the sleep further, and at SF6 with + // the 16-symbol profile that collapses the first few levels onto the same + // point. Reporting the requested value there would be a lie. + char margin[24]; + margin[0] = 0; + if (status.last_error == 0 && isValidRxPowerSavingPeriod(config->sleep_us) && + config->level >= 1 && config->level <= RX_POWERSAVING_GUARDED_LEVELS && + sf >= 5 && sf <= 12 && isfinite(bw) && bw > 0.0f) { + const uint8_t preamble = config->preamble ? config->preamble : rxPowerSavingPreambleForSF(sf); + const float symbol_us = (1000.0f * (float)(1UL << sf)) / bw; + const float effective = (float)preamble - (float)config->sleep_us / symbol_us; + // No %f on this platform's printf, and rounding the whole and fractional + // parts separately overflows: 4.999 came out as "4.10". + const float scaled = effective * 10.0f + (effective < 0.0f ? -0.5f : 0.5f); + if (isfinite(scaled) && scaled > (float)INT_MIN && scaled < (float)INT_MAX) { + const int tenths = (int)scaled; + snprintf(margin, sizeof(margin), ",catch=%d.%d", tenths / 10, + tenths < 0 ? -(tenths % 10) : tenths % 10); + } + } + + // Overdrive has to be visible here, otherwise it reads as an ordinary level + // and nobody remembers it runs outside the datasheet rule. + int len = snprintf(reply, reply_size, + "> desired=%s,effective=%s,supported=%s,err=%d,fail=%lu,level=%lu%s%s,preamble=%lu,rx=%lu,sleep=%lu", + config->enabled ? "on" : "off", status.armed ? "armed" : "continuous", + status.supported ? "yes" : "no", (int)status.last_error, + (unsigned long)status.arm_failures, (unsigned long)config->level, + rxPowerSavingLevelTag(config->level), margin, + (unsigned long)config->preamble, (unsigned long)config->rx_us, + (unsigned long)config->sleep_us); + + // Only report the periods the hardware really runs with when a driver had to + // clamp them - printing them always would not fit the 160 byte reply buffer. + if (len > 0 && (size_t)len < reply_size && status.armed && + (status.effective_rx_us != config->rx_us || + status.effective_sleep_us != config->sleep_us)) { + snprintf(reply + len, reply_size - (size_t)len, ",erx=%lu,eslp=%lu", + (unsigned long)status.effective_rx_us, + (unsigned long)status.effective_sleep_us); + } +} diff --git a/src/helpers/radiolib/RXPowerSavingCLI.h b/src/helpers/radiolib/RXPowerSavingCLI.h new file mode 100644 index 000000000..62acfb95a --- /dev/null +++ b/src/helpers/radiolib/RXPowerSavingCLI.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +#include "RXPowerSaving.h" + +class RXPowerSavingCLI { +public: + static bool set(const char* value, uint8_t sf, float bw, RxPowerSavingConfig* config, + RxPowerSavingControl* control, char* reply, size_t reply_size); + // sf/bw are needed to turn the stored periods back into symbols, which is + // what the level scale is expressed in. + static void get(const RxPowerSavingConfig* config, const RxPowerSavingControl* control, + uint8_t sf, float bw, char* reply, size_t reply_size); +}; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index e4d2ba1c2..59c8def0d 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -11,6 +11,13 @@ #define NUM_NOISE_FLOOR_SAMPLES 64 #define SAMPLING_THRESHOLD 14 +// PowerSaving variant +#define NF_CALIB_INTERVAL_MS 60000UL +#define NF_CALIB_TIMEOUT_MS 5000UL +#define NF_CALIB_SETTLE_MS 20UL +#define NF_CALIB_SAMPLE_INTERVAL_MS 1UL +#define NF_CALIB_MAX_SAMPLE_ATTEMPTS (NUM_NOISE_FLOOR_SAMPLES * 4U) + static volatile uint8_t state = STATE_IDLE; // this function is called when a complete packet @@ -48,17 +55,39 @@ uint32_t RadioLibWrapper::getRngSeed() { } void RadioLibWrapper::setTxPower(int8_t dbm) { + // PowerSaving variant + prepareForRadioConfig(); + #if defined(USE_LR2021) + // PowerSaving variant + // On LR2021, setOutputPower() writes the PA config and TxParams, which are + // standby-only commands. recvRaw() keeps state == STATE_RX after readData on + // this platform ("LR2021 stays in Rx"), so - unlike the SX126x path, which + // falls back to STATE_IDLE and re-arms on the next recvRaw() - nothing calls + // startReceive() again by itself. Writing the PA config while listening could + // therefore leave the receiver down until the next reboot. Drop to standby + // first and let checkRecv() re-arm Rx, as resetAGC() and + // applySideDetectorConfig() already do. + idle(); #endif _radio->setOutputPower(dbm); } void RadioLibWrapper::idle() { + // PowerSaving variant + if (_rx_ps_armed) stopReceiveDutyCycle(); + _radio->standby(); state = STATE_IDLE; // need another startReceive() } +// PowerSaving variant +void RadioLibWrapper::powerOff() { + if (_rx_ps_armed) stopReceiveDutyCycle(); + _radio->sleep(); +} + void RadioLibWrapper::triggerNoiseFloorCalibrate(int threshold) { _threshold = threshold; if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES) { // ignore trigger if currently sampling @@ -72,40 +101,150 @@ void RadioLibWrapper::doResetAGC() { } void RadioLibWrapper::resetAGC() { - // make sure we're not mid-receive of packet! - if ((state & STATE_INT_READY) != 0 || isReceivingPacket()) return; + // PowerSaving variant + // make sure we're not mid-receive or mid-transmit of a packet + if (isPacketPendingOrReceiving() || state == STATE_TX_WAIT) return; + + // PowerSaving variant + if (_rx_ps_armed) stopReceiveDutyCycle(); doResetAGC(); - state = STATE_IDLE; // trigger a startReceive() + state = STATE_IDLE; - // Reset noise floor sampling so it reconverges from scratch. - // Without this, a stuck _noise_floor of -120 makes the sampling threshold - // too low (-106) to accept normal samples (~-105), self-reinforcing the - // stuck value even after the receiver has recovered. + // PowerSaving variant + // Recalibrate synchronously so callers never observe the temporary zero + // used to bypass a stale sampling threshold after an AGC reset. + const int16_t previous_noise_floor = _noise_floor; _noise_floor = 0; _num_floor_samples = 0; _floor_sample_sum = 0; -} -void RadioLibWrapper::loop() { - if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { - if (!isReceivingPacket()) { - int rssi = getCurrentRSSI(); - if (rssi < _noise_floor + SAMPLING_THRESHOLD) { // only consider samples below current floor + sampling THRESHOLD - _num_floor_samples++; - _floor_sample_sum += rssi; + // PowerSaving variant + _nf_calib_active = true; // force startReceiveMode() into continuous RX + bool packet_in_progress = false; + int16_t err = startReceiveMode(); + if (err == RADIOLIB_ERR_NONE) { + state = STATE_RX; + delay(NF_CALIB_SETTLE_MS); + + uint16_t sample_attempts = 0; + while (_num_floor_samples < NUM_NOISE_FLOOR_SAMPLES && + sample_attempts++ < NF_CALIB_MAX_SAMPLE_ATTEMPTS) { + if (isPacketPendingOrReceiving()) { + packet_in_progress = true; + break; + } + sampleNoiseFloorOnce(); + if (_num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { + delay(NF_CALIB_SAMPLE_INTERVAL_MS); } } - } else if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES && _floor_sample_sum != 0) { - _noise_floor = _floor_sample_sum / NUM_NOISE_FLOOR_SAMPLES; - if (_noise_floor < -120) { - _noise_floor = -120; // clamp to lower bound of -120dBi - } - _floor_sample_sum = 0; + } - #ifdef MESH_DEBUG_NOISE_FLOOR - MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor); - #endif + if (!publishNoiseFloor()) { + _noise_floor = previous_noise_floor; + _num_floor_samples = 0; + _floor_sample_sum = 0; + } + + _nf_calib_active = false; + _nf_last_calib = millis(); + + // A frame can start after resetAGC()'s initial idle check. Keep continuous + // RX alive until recvRaw() consumes it; restarting RX here would abort a + // frame that is between preamble detection and RX_DONE. + if (!packet_in_progress) packet_in_progress = isPacketPendingOrReceiving(); + if (!packet_in_progress) requestRestartRecv(); +} + +// PowerSaving variant +// Clear the RX/idle state so the next loop calls startRecv() again, without +// dropping an STATE_INT_READY that the ISR may raise while we are in here. +// A plain `state = STATE_IDLE` loses that flag (and with it, a received +// packet) when setFlag() fires between the test and the store. +void RadioLibWrapper::requestRestartRecv() { + noInterrupts(); + if ((state & ~STATE_INT_READY) != STATE_TX_WAIT) { + state &= STATE_INT_READY; // STATE_IDLE, but keep a pending interrupt + } + interrupts(); +} + +// PowerSaving variant +bool RadioLibWrapper::isPacketPendingOrReceiving() { + return (state & STATE_INT_READY) != 0 || isReceivingPacket(); +} + +// PowerSaving variant +void RadioLibWrapper::sampleNoiseFloorOnce() { + int rssi = getCurrentRSSI(); + if (rssi < _noise_floor + SAMPLING_THRESHOLD) { + _num_floor_samples++; + _floor_sample_sum += rssi; + } +} + +// PowerSaving variant +bool RadioLibWrapper::publishNoiseFloor() { + if (_num_floor_samples < NUM_NOISE_FLOOR_SAMPLES || _floor_sample_sum == 0) return false; + + _noise_floor = _floor_sample_sum / NUM_NOISE_FLOOR_SAMPLES; + if (_noise_floor < -120) { + _noise_floor = -120; // clamp to lower bound of -120dBi + } + _floor_sample_sum = 0; + + #ifdef MESH_DEBUG_NOISE_FLOOR + MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor); + #endif + return true; +} + +// PowerSaving variant +void RadioLibWrapper::noiseFloorCalibCheck() { + unsigned long now = millis(); + if (_nf_calib_active) { + if (!_rx_ps_enabled || (long)(now - _nf_calib_deadline) >= 0) { + endNoiseFloorCalib(now); + } else if (_rx_ps_armed && !isPacketPendingOrReceiving()) { + // A packet may have delayed the RXPS-to-continuous-RX transition. + requestRestartRecv(); + } + } else if (_rx_ps_enabled && _rx_ps_armed && state == STATE_RX && + (_nf_last_calib == 0 || now - _nf_last_calib >= NF_CALIB_INTERVAL_MS) && + !isPacketPendingOrReceiving()) { + _nf_calib_active = true; + _nf_calib_deadline = now + NF_CALIB_TIMEOUT_MS; + _nf_sample_from = now + NF_CALIB_SETTLE_MS; + _num_floor_samples = 0; + _floor_sample_sum = 0; + if (!isPacketPendingOrReceiving()) requestRestartRecv(); + } +} + +// PowerSaving variant +void RadioLibWrapper::endNoiseFloorCalib(unsigned long now) { + _nf_calib_active = false; + _nf_last_calib = now; + if (!isPacketPendingOrReceiving()) requestRestartRecv(); +} + +// PowerSaving variant +void RadioLibWrapper::loop() { + if (_rx_ps_restart_pending && (state & ~STATE_INT_READY) != STATE_TX_WAIT && + !isPacketPendingOrReceiving()) { + _rx_ps_restart_pending = false; + requestRestartRecv(); + } + noiseFloorCalibCheck(); + + if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { + if (!_rx_ps_armed && !(_nf_calib_active && (long)(millis() - _nf_sample_from) < 0) && + !isReceivingPacket()) { + sampleNoiseFloorOnce(); + } + } else if (publishNoiseFloor()) { + if (_nf_calib_active) endNoiseFloorCalib(millis()); } } @@ -113,7 +252,9 @@ void RadioLibWrapper::startRecv() { #if defined(USE_LR2021) _radio->standby(); // without this LR2021 can throw -706 when calling startReceive after hardware CAD when side detectors are enabled #endif - int err = _radio->startReceive(); + + // PowerSaving variant + int err = startReceiveMode(); if (err == RADIOLIB_ERR_NONE) { state = STATE_RX; } else { @@ -121,6 +262,123 @@ void RadioLibWrapper::startRecv() { } } +// PowerSaving variant +int16_t RadioLibWrapper::startReceiveMode() { + // Periodic calibration switches RXPS to continuous RX. Re-check at the + // hardware transition so a preamble that arrived after the scheduler's + // idle check is not aborted by stopReceiveDutyCycle(). + if (_nf_calib_active && _rx_ps_armed && isPacketPendingOrReceiving()) { + return RADIOLIB_ERR_NONE; + } + if (_rx_ps_armed) stopReceiveDutyCycle(); + if (!_rx_ps_enabled || _nf_calib_active) { + _rx_ps_armed = false; + const int16_t err = startContinuousReceive(); + _rx_ps_last_error = err; + return err; + } + + if (!_rx_ps_arm_retry.canAttempt()) { + // arming has failed repeatedly; stop paying for a doomed SPI round-trip on + // every RX restart. A config change (setRxPowerSaving) re-enables retries. + _rx_ps_armed = false; + const int16_t err = startContinuousReceive(); + if (err != RADIOLIB_ERR_NONE) _rx_ps_last_error = err; + return err; + } + + const RadioLibIrqFlags_t irq_flags = + RADIOLIB_IRQ_RX_DEFAULT_FLAGS | + (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED); + const RadioLibIrqFlags_t irq_mask = + (1UL << RADIOLIB_IRQ_RX_DONE) | + (1UL << RADIOLIB_IRQ_TIMEOUT) | + (1UL << RADIOLIB_IRQ_CRC_ERR) | + (1UL << RADIOLIB_IRQ_HEADER_ERR); + + uint32_t eff_rx_us = _rx_ps_rx_us; + uint32_t eff_sleep_us = _rx_ps_sleep_us; + int16_t err = armDutyCycle(irq_flags, irq_mask, &eff_rx_us, &eff_sleep_us); + if (err == RADIOLIB_ERR_NONE) { + _rx_ps_armed = true; + _rx_ps_last_error = RADIOLIB_ERR_NONE; + _rx_ps_arm_retry.recordSuccess(); + _rx_ps_eff_rx_us = eff_rx_us; + _rx_ps_eff_sleep_us = eff_sleep_us; + return err; + } + + _rx_ps_armed = false; + _rx_ps_last_error = err; + _rx_ps_eff_rx_us = 0; + _rx_ps_eff_sleep_us = 0; + n_rx_ps_arm_failures++; + _rx_ps_arm_retry.recordFailure(); + MESH_DEBUG_PRINTLN("RadioLibWrapper: startReceiveDutyCycle(%d), continuous RX fallback", err); + stopDutyCycleHardware(); + int16_t fallback_err = startContinuousReceive(); + if (fallback_err != RADIOLIB_ERR_NONE) _rx_ps_last_error = fallback_err; + return fallback_err; +} + +// PowerSaving variant +void RadioLibWrapper::stopReceiveDutyCycle() { + int16_t err = stopDutyCycleHardware(); + _rx_ps_armed = false; + _rx_ps_eff_rx_us = 0; + _rx_ps_eff_sleep_us = 0; + if (err != RADIOLIB_ERR_NONE) _rx_ps_last_error = err; +} + +// PowerSaving variant +bool RadioLibWrapper::isPacketReady() { + if (!_rx_ps_armed) return true; + return _radio->checkIrq(RADIOLIB_IRQ_RX_DONE) != 0; +} + +// PowerSaving variant +void RadioLibWrapper::prepareForRadioConfig() { + if (!_rx_ps_armed) return; + + stopReceiveDutyCycle(); + requestRestartRecv(); +} + +// PowerSaving variant +bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) { + if (enabled && (!isValidRxPowerSavingPeriod(rx_us) || !isValidRxPowerSavingPeriod(sleep_us))) { + _rx_ps_enabled = false; + _rx_ps_last_error = RX_POWERSAVING_ERR_TIMING; + requestRestartRecv(); + return false; + } + if (enabled && !supportsRxPowerSaving()) return false; + + _rx_ps_enabled = enabled; + _rx_ps_rx_us = rx_us; + _rx_ps_sleep_us = sleep_us; + _rx_ps_last_error = RADIOLIB_ERR_NONE; + _rx_ps_arm_retry.reset(); // new config, give the hardware a fresh chance + _rx_ps_restart_pending = true; + if ((state & ~STATE_INT_READY) != STATE_TX_WAIT && !isPacketPendingOrReceiving()) { + _rx_ps_restart_pending = false; + requestRestartRecv(); + } + return true; +} + +// PowerSaving variant +RxPowerSavingStatus RadioLibWrapper::getRxPowerSavingStatus() const { + RxPowerSavingStatus status; + status.supported = supportsRxPowerSaving(); + status.armed = _rx_ps_armed; + status.last_error = _rx_ps_config_error ? RX_POWERSAVING_ERR_TIMING : _rx_ps_last_error; + status.arm_failures = n_rx_ps_arm_failures; + status.effective_rx_us = _rx_ps_eff_rx_us; + status.effective_sleep_us = _rx_ps_eff_sleep_us; + return status; +} + bool RadioLibWrapper::isInRecvMode() const { return (state & ~STATE_INT_READY) == STATE_RX; } @@ -128,17 +386,25 @@ bool RadioLibWrapper::isInRecvMode() const { int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { int len = 0; if (state & STATE_INT_READY) { - len = _radio->getPacketLength(); - if (len > 0) { - if (len > sz) { len = sz; } - int err = _radio->readData(bytes, len); - if (err != RADIOLIB_ERR_NONE) { - MESH_DEBUG_PRINTLN("RadioLibWrapper: error: readData(%d)", err); - len = 0; - n_recv_errors++; - } else { - // Serial.print(" readData() -> "); Serial.println(len); - n_recv++; + // PowerSaving variant + if (isPacketReady()) { + if (_rx_ps_armed) stopReceiveDutyCycle(); + len = _radio->getPacketLength(); + if (len > 0) { + if (len > sz) { len = sz; } + // PowerSaving variant + _last_snr = _radio->getSNR(); + _last_rssi = _radio->getRSSI(); + _last_metrics_valid = true; + int err = _radio->readData(bytes, len); + if (err != RADIOLIB_ERR_NONE) { + MESH_DEBUG_PRINTLN("RadioLibWrapper: error: readData(%d)", err); + len = 0; + n_recv_errors++; + } else { + // Serial.print(" readData() -> "); Serial.println(len); + n_recv++; + } } } #if defined(USE_LR2021) @@ -149,12 +415,8 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { } if (state != STATE_RX) { - int err = _radio->startReceive(); - if (err == RADIOLIB_ERR_NONE) { - state = STATE_RX; - } else { - MESH_DEBUG_PRINTLN("RadioLibWrapper: error: startReceive(%d)", err); - } + // PowerSaving variant + startRecv(); } return len; } @@ -164,6 +426,9 @@ uint32_t RadioLibWrapper::getEstAirtimeFor(int len_bytes) { } bool RadioLibWrapper::startSendRaw(const uint8_t* bytes, int len) { + // PowerSaving variant + if (_rx_ps_armed) stopReceiveDutyCycle(); + _board->onBeforeTransmit(); int err = _radio->startTransmit((uint8_t *) bytes, len); if (err == RADIOLIB_ERR_NONE) { @@ -196,17 +461,23 @@ int16_t RadioLibWrapper::performChannelScan() { } bool RadioLibWrapper::isChannelActive() { + // PowerSaving variant // int.thresh: RSSI-based interference detection (relative to noise floor) - if (_threshold != 0 && getCurrentRSSI() > _noise_floor + _threshold) return true; + if (_threshold != 0 && !(_rx_ps_armed && isChipBusy()) && + getCurrentRSSI() > _noise_floor + _threshold) return true; // cad: hardware channel activity detection if (_cad_enabled) { + // PowerSaving variant + if (_rx_ps_armed) stopReceiveDutyCycle(); int16_t result = performChannelScan(); // scanChannel() triggers DIO interrupt (CAD done) which sets STATE_INT_READY // via setFlag() ISR. Clear it before restarting RX so recvRaw() doesn't // try to read a non-existent packet and count a spurious recv error. state = STATE_IDLE; startRecv(); + // PowerSaving variant + if (result == RADIOLIB_LORA_DETECTED) ++_cad_busy_count; if (result != RADIOLIB_CHANNEL_FREE) return true; } @@ -214,10 +485,14 @@ bool RadioLibWrapper::isChannelActive() { } float RadioLibWrapper::getLastRSSI() const { - return _radio->getRSSI(); + // PowerSaving variant + if (_last_metrics_valid) return _last_rssi; + return _rx_ps_armed ? 0 : _radio->getRSSI(); } float RadioLibWrapper::getLastSNR() const { - return _radio->getSNR(); + // PowerSaving variant + if (_last_metrics_valid) return _last_snr; + return _rx_ps_armed ? 0 : _radio->getSNR(); } // Approximate SNR threshold per SF for successful reception (based on Semtech datasheets) diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 77dd93116..509d9dd07 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -2,6 +2,8 @@ #include #include +// PowerSaving variant +#include "RXPowerSaving.h" #ifdef USE_CC310_HW_CRYPTO #include @@ -11,28 +13,89 @@ struct PacketMillis { uint32_t payloadMillis; // header-valid -> rx-done deadline }; -class RadioLibWrapper : public mesh::Radio { +// PowerSaving variant +class RadioLibWrapper : public mesh::Radio, public RxPowerSavingControl { protected: PhysicalLayer* _radio; mesh::MainBoard* _board; uint32_t n_recv, n_sent, n_recv_errors; + // PowerSaving variant + uint32_t _cad_busy_count = 0; int16_t _noise_floor, _threshold; + // PowerSaving variant + float _last_rssi, _last_snr; + bool _last_metrics_valid; bool _cad_enabled; uint16_t _num_floor_samples; int32_t _floor_sample_sum; uint8_t _preamble_sf; + // PowerSaving variant + bool _rx_ps_restart_pending = false; + bool _rx_ps_config_error = false; + bool _rx_ps_enabled; + bool _rx_ps_armed; + uint32_t _rx_ps_rx_us; + uint32_t _rx_ps_sleep_us; + uint32_t _rx_ps_eff_rx_us; // periods the driver actually armed with + uint32_t _rx_ps_eff_sleep_us; + int16_t _rx_ps_last_error; + uint32_t n_rx_ps_arm_failures; + RxPowerSavingArmRetryState _rx_ps_arm_retry; + + bool _nf_calib_active; + unsigned long _nf_last_calib; + unsigned long _nf_calib_deadline; + unsigned long _nf_sample_from; + void idle(); void startRecv(); + + // PowerSaving variant + void requestRestartRecv(); + bool isPacketPendingOrReceiving(); + void prepareForRadioConfig(); + void sampleNoiseFloorOnce(); + bool publishNoiseFloor(); + void noiseFloorCalibCheck(); + void endNoiseFloorCalib(unsigned long now); + int16_t startReceiveMode(); + void stopReceiveDutyCycle(); + // eff_rx_us/eff_sleep_us report the periods the driver really programmed, + // which may be clamped to satisfy a hardware constraint. + virtual int16_t armDutyCycle(RadioLibIrqFlags_t irq_flags, + RadioLibIrqFlags_t irq_mask, + uint32_t* eff_rx_us, uint32_t* eff_sleep_us) { + (void)irq_flags; + (void)irq_mask; + (void)eff_rx_us; + (void)eff_sleep_us; + return RADIOLIB_ERR_UNSUPPORTED; + } + virtual int16_t stopDutyCycleHardware() { return _radio->standby(); } + virtual int16_t startContinuousReceive() { return _radio->startReceive(); } + virtual bool isPacketReady(); + virtual bool isChipBusy() { return false; } + float packetScoreInt(float snr, int sf, int packet_len); virtual bool isReceivingPacket() =0; virtual void doResetAGC(); public: - RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _preamble_sf(0) { n_recv = n_sent = 0; } + // PowerSaving variant + RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) + : _radio(&radio), _board(&board), _last_rssi(0), _last_snr(0), _last_metrics_valid(false), + _preamble_sf(0), _rx_ps_enabled(false), _rx_ps_armed(false), + _rx_ps_rx_us(RX_POWERSAVING_DEFAULT_RX_US), + _rx_ps_sleep_us(RX_POWERSAVING_DEFAULT_SLEEP_US), _rx_ps_eff_rx_us(0), + _rx_ps_eff_sleep_us(0), _rx_ps_last_error(RADIOLIB_ERR_NONE), + n_rx_ps_arm_failures(0), _nf_calib_active(false), + _nf_last_calib(0), _nf_calib_deadline(0), _nf_sample_from(0) { + n_recv = n_sent = n_recv_errors = 0; + } void begin() override; - virtual void powerOff() { _radio->sleep(); } + virtual void powerOff(); int recvRaw(uint8_t* bytes, int sz) override; uint32_t getEstAirtimeFor(int len_bytes) override; bool startSendRaw(const uint8_t* bytes, int len) override; @@ -41,6 +104,15 @@ public: bool isInRecvMode() const override; bool isChannelActive(); + // PowerSaving variant + virtual bool supportsRxPowerSaving() const { return false; } + void setRxPowerSavingConfigError(bool invalid) override { _rx_ps_config_error = invalid; } + bool setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) override; + RxPowerSavingStatus getRxPowerSavingStatus() const override; + bool isRxPowerSavingCalibrationActive() const override { return _nf_calib_active; } + + bool hasPendingReceive() { return isPacketPendingOrReceiving(); } + bool isReceiving() override { if (isReceivingPacket()) return true; @@ -65,10 +137,21 @@ public: void loop() override; + // PowerSaving variant + uint32_t getCADBusyCount() const { return _cad_busy_count; } + uint32_t getPacketsRecv() const { return n_recv; } uint32_t getPacketsRecvErrors() const { return n_recv_errors; } uint32_t getPacketsSent() const { return n_sent; } - void resetStats() { n_recv = n_sent = n_recv_errors = 0; } + + // PowerSaving variant + void resetStats() { + n_recv = n_sent = n_recv_errors = n_rx_ps_arm_failures = 0; + _cad_busy_count = 0; + // Clearing diagnostics also grants a fresh set of arm attempts. If they + // fail again, the normal three-failure continuous-RX fallback applies. + _rx_ps_arm_retry.reset(); + } virtual float getLastRSSI() const override; virtual float getLastSNR() const override;