Files
MeshCore-Solo/src/helpers/radiolib/RadioLibWrappers.cpp
Jakub 5b58049139 feat(power): battery saving — hardware duty-cycle RX + adaptive TX power
Two independent, default-off toggles under Settings › Radio.

Pwr save: hardware RX duty-cycle (SX126x SetRxDutyCycle via
startReceiveDutyCycleAuto). The chip cycles RX↔sleep and wakes on a preamble —
no MCU state machine; recvRaw reads the packet exactly as in continuous RX.
Falls back to continuous RX on non-SX126x. (Replaces an earlier software-CAD
state machine that fought the hardware: polling a warm-sleeping chip gave a
phantom-busy channel that stalled TX ~4 s and dropped ACKs in the scan gaps.)

Auto pwr: Adaptive Power Control. tx_power_dbm becomes a ceiling; actual TX
power tracks the reverse-link SNR margin (measured above the per-SF demod floor,
EWMA-smoothed, proportional step with a deadband). Feedback comes from direct /
room-server ACKs and, for channels (no ACK), from hearing a repeater rebroadcast
our own flood; a lost confirmation ramps power back up so channel sends can't get
stranded below what the repeaters can hear.

Prefs schema 0xC0DE0009 (rx_powersave, tx_apc). Radio page / name bar show the
live TX power; noise floor reads n/a while duty-cycling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:15:42 +02:00

227 lines
6.8 KiB
C++

#define RADIOLIB_STATIC_ONLY 1
#include "RadioLibWrappers.h"
#define STATE_IDLE 0
#define STATE_RX 1
#define STATE_TX_WAIT 3
#define STATE_TX_DONE 4
#define STATE_INT_READY 16
#define NUM_NOISE_FLOOR_SAMPLES 64
#define SAMPLING_THRESHOLD 14
static volatile uint8_t state = STATE_IDLE;
// this function is called when a complete packet
// is transmitted by the module
static
#if defined(ESP8266) || defined(ESP32)
ICACHE_RAM_ATTR
#endif
void setFlag(void) {
// we sent a packet, set the flag
state |= STATE_INT_READY;
}
void RadioLibWrapper::begin() {
_radio->setPacketReceivedAction(setFlag); // this is also SentComplete interrupt
_preamble_sf = getSpreadingFactor();
_radio->setPreambleLength(preambleLengthForSF(_preamble_sf)); // longer preamble for lower SF improves reliability
state = STATE_IDLE;
if (_board->getStartupReason() == BD_STARTUP_RX_PACKET) { // received a LoRa packet (while in deep sleep)
setFlag(); // LoRa packet is already received
}
_noise_floor = 0;
_threshold = 0;
// start average out some samples
_num_floor_samples = 0;
_floor_sample_sum = 0;
}
uint32_t RadioLibWrapper::getRngSeed() {
return _radio->random(0x7FFFFFFF);
}
void RadioLibWrapper::setTxPower(int8_t dbm) {
_tx_dbm = dbm;
_radio->setOutputPower(dbm);
}
void RadioLibWrapper::idle() {
_radio->standby();
state = STATE_IDLE; // need another startReceive()
}
void RadioLibWrapper::triggerNoiseFloorCalibrate(int threshold) {
_threshold = threshold;
if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES) { // ignore trigger if currently sampling
_num_floor_samples = 0;
_floor_sample_sum = 0;
}
}
void RadioLibWrapper::doResetAGC() {
_radio->sleep(); // warm sleep to reset analog frontend
}
void RadioLibWrapper::resetAGC() {
// make sure we're not mid-receive of packet!
if ((state & STATE_INT_READY) != 0 || isReceivingPacket()) return;
doResetAGC();
state = STATE_IDLE; // trigger a startReceive()
// 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.
_noise_floor = 0;
_num_floor_samples = 0;
_floor_sample_sum = 0;
}
void RadioLibWrapper::loop() {
// Power-save toggled vs the currently-armed RX mode: re-arm into the other mode
// once the radio is idle (don't interrupt an in-flight TX or an unread RX-done).
if (_power_save != _ps_active) {
if (state != STATE_TX_WAIT && !(state & STATE_INT_READY) && !isReceivingPacket()) armRecv();
return;
}
// In power-save the SX126x hardware duty-cycles RX on its own — nothing to poll
// here, and noise-floor sampling (used only by the disabled interference check)
// would read a chip that is asleep most of the time.
if (_power_save) return;
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;
}
}
} 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;
MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor);
}
}
void RadioLibWrapper::startRecv() {
armRecv();
}
// Arm the receiver. In power-save mode this starts the SX126x hardware RX
// duty-cycle (the chip cycles RX↔sleep and latches a preamble on its own);
// otherwise a continuous RX. Falls back to continuous RX if the modem doesn't
// support duty-cycle (base startPowerSaveRecv() returns UNSUPPORTED).
void RadioLibWrapper::armRecv() {
if (_power_save) {
int16_t e = startPowerSaveRecv();
if (e == RADIOLIB_ERR_NONE) { state = STATE_RX; _ps_active = true; return; }
MESH_DEBUG_PRINTLN("RadioLibWrapper: RX duty-cycle unsupported (%d) — power-save off", (int)e);
_power_save = false;
}
_ps_active = false;
int err = _radio->startReceive();
if (err == RADIOLIB_ERR_NONE) {
state = STATE_RX;
} else {
MESH_DEBUG_PRINTLN("RadioLibWrapper: error: startReceive(%d)", err);
}
}
bool RadioLibWrapper::isInRecvMode() const {
return (state & ~STATE_INT_READY) == STATE_RX;
}
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++;
}
}
state = STATE_IDLE; // need another startReceive()
}
if (state != STATE_RX) {
armRecv(); // continuous RX, or re-arm the duty-cycle in power-save mode
}
return len;
}
uint32_t RadioLibWrapper::getEstAirtimeFor(int len_bytes) {
return _radio->getTimeOnAir(len_bytes) / 1000;
}
bool RadioLibWrapper::startSendRaw(const uint8_t* bytes, int len) {
_board->onBeforeTransmit();
int err = _radio->startTransmit((uint8_t *) bytes, len);
if (err == RADIOLIB_ERR_NONE) {
state = STATE_TX_WAIT;
return true;
}
MESH_DEBUG_PRINTLN("RadioLibWrapper: error: startTransmit(%d)", err);
idle(); // trigger another startRecv()
_board->onAfterTransmit();
return false;
}
bool RadioLibWrapper::isSendComplete() {
if (state & STATE_INT_READY) {
state = STATE_IDLE;
n_sent++;
return true;
}
return false;
}
void RadioLibWrapper::onSendFinished() {
_radio->finishTransmit();
_board->onAfterTransmit();
state = STATE_IDLE;
}
bool RadioLibWrapper::isChannelActive() {
return _threshold == 0
? false // interference check is disabled
: getCurrentRSSI() > _noise_floor + _threshold;
}
float RadioLibWrapper::getLastRSSI() const {
return _radio->getRSSI();
}
float RadioLibWrapper::getLastSNR() const {
return _radio->getSNR();
}
float RadioLibWrapper::packetScoreInt(float snr, int sf, int packet_len) {
if (sf < 7) return 0.0f;
float floor = snrFloorForSF(sf); // min SNR for a chance of success
if (snr < floor) return 0.0f; // below the demod floor → no chance
auto success_rate_based_on_snr = (snr - floor) / 10.0;
auto collision_penalty = 1 - (packet_len / 256.0); // Assuming max packet of 256 bytes
return max(0.0, min(1.0, success_rate_based_on_snr * collision_penalty));
}