Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/cli_commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,30 @@ This document provides an overview of CLI commands that can be sent to MeshCore

---

#### View or change the relay (forward) duty cycle limit
**Usage:**
- `get fwd.dutycycle`
- `set fwd.dutycycle <value>`

**Parameters:**
- `value`: Duty cycle percentage (1-100) that this node may spend re-transmitting other
nodes' packets. Airtime spent forwarding is accounted over a rolling window, and once
the budget is spent further forwards are dropped (and counted) rather than delayed.
Traffic this node originates, including its own ACKs, is not charged to this budget.

**Default:** `100%` (no separate limit; forwards are still bounded by the overall duty cycle)

**Examples:**
- `set fwd.dutycycle 100` — no separate relay limit
- `set fwd.dutycycle 10` — relay at most 10% of each window
- `set fwd.dutycycle 1` — relay at most 1% of each window (strictest EU requirement)

> **Note:** Takes effect after reboot. `stats-radio` reports relay usage as
> `fwd_air_secs` (total), `fwd_window_secs` / `fwd_limit_secs` (current window and cap)
> and `fwd_dropped` (forwards refused).

---

#### View or change the airtime factor (duty cycle limit)
> **Deprecated** as of firmware v1.15.0. Use [`get/set dutycycle`](#view-or-change-the-duty-cycle-limit) instead.

Expand Down
5 changes: 5 additions & 0 deletions examples/companion_radio/MyMesh.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,10 @@ float MyMesh::getAirtimeBudgetFactor() const {
return _prefs.airtime_factor;
}

float MyMesh::getForwardAirtimeBudgetFactor() const {
return _prefs.fwd_airtime_factor;
}

int MyMesh::getInterferenceThreshold() const {
return _prefs.interference_threshold;
}
Expand Down Expand Up @@ -1019,6 +1023,7 @@ void MyMesh::begin(bool has_display) {
_prefs.tx_delay_factor = constrain(_prefs.tx_delay_factor, 0, 2.0f);
_prefs.direct_tx_delay_factor = constrain(_prefs.direct_tx_delay_factor, 0, 2.0f);
_prefs.airtime_factor = constrain(_prefs.airtime_factor, 0, 9.0f);
_prefs.fwd_airtime_factor = constrain(_prefs.fwd_airtime_factor, 0, 9.0f);
_prefs.freq = constrain(_prefs.freq, 150.0f, 2500.0f);
_prefs.bw = constrain(_prefs.bw, 7.8f, 500.0f);
_prefs.sf = constrain(_prefs.sf, 5, 12);
Expand Down
1 change: 1 addition & 0 deletions examples/companion_radio/MyMesh.h
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost {

protected:
float getAirtimeBudgetFactor() const override;
float getForwardAirtimeBudgetFactor() const override;
int getInterferenceThreshold() const override;
bool getCADEnabled() const override;
int getAGCResetInterval() const override {
Expand Down
4 changes: 4 additions & 0 deletions examples/companion_radio/NodePrefs.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
class NodePrefs : public ConfigSerializer { // persisted to file
public:
float airtime_factor = 0;
float fwd_airtime_factor = 0; // separate duty cycle limit for relayed traffic (0 = no separate limit)
char node_name[32];
double node_lat = 0, node_lon = 0;
float freq = 0;
Expand Down Expand Up @@ -75,6 +76,7 @@ class NodePrefs : public ConfigSerializer { // persisted to file
def("fem_txgain", _parent->radio_fem_txgain);
def("tx", _parent->tx_power_dbm);
def("af", _parent->airtime_factor);
def("fwd_af", _parent->fwd_airtime_factor);
def("rxdelay", _parent->rx_delay_base);
def("f_txdelay", _parent->tx_delay_factor);
def("d_txdelay", _parent->direct_tx_delay_factor);
Expand All @@ -96,6 +98,8 @@ class NodePrefs : public ConfigSerializer { // persisted to file
void setCodingRate(uint8_t cr) override { _parent->cr = cr; markDirty(); }
float getAirtimeFactor() const override { return _parent->airtime_factor; }
void setAirtimeFactor(float af) override { _parent->airtime_factor = af; markDirty(); }
float getForwardAirtimeFactor() const override { return _parent->fwd_airtime_factor; }
void setForwardAirtimeFactor(float af) override { _parent->fwd_airtime_factor = af; markDirty(); }
bool isCadEnabled() const override { return _parent->cad_enabled; }
void setCadEnabled(bool en) override { _parent->cad_enabled = en; markDirty(); }
uint8_t getIntThresh() const override { return _parent->interference_threshold; }
Expand Down
5 changes: 3 additions & 2 deletions examples/simple_repeater/MyMesh.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ uint8_t MyMesh::handleAnonClockReq(const mesh::Identity& sender, uint32_t sender
if (_prefs.disable_fwd) { // is this repeater currently disabled
reply_data[8] |= 0x80; // is disabled
}
// TODO: add some kind of moving-window utilisation metric, so can query 'how busy' is this repeater
// relay utilisation (moving-window airtime spent forwarding) is reported by the 'stats-radio' CLI command
return 9; // reply length
}
return 0;
Expand Down Expand Up @@ -1167,7 +1167,8 @@ void MyMesh::formatStatsReply(char *reply) {
}

void MyMesh::formatRadioStatsReply(char *reply) {
StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime());
StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime(),
getForwardAirTime(), getForwardBudgetUsed(), getForwardBudgetLimit(), getNumForwardDropped());
}

void MyMesh::formatPacketStatsReply(char *reply) {
Expand Down
4 changes: 4 additions & 0 deletions examples/simple_repeater/MyMesh.h
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
return _prefs.airtime_factor;
}

float getForwardAirtimeBudgetFactor() const override {
return _prefs.fwd_airtime_factor;
}

bool allowPacketForward(const mesh::Packet* packet) override;
const char* getLogDateTime() override;
void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override;
Expand Down
3 changes: 2 additions & 1 deletion examples/simple_room_server/MyMesh.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -888,7 +888,8 @@ void MyMesh::formatStatsReply(char *reply) {
}

void MyMesh::formatRadioStatsReply(char *reply) {
StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime());
StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime(),
getForwardAirTime(), getForwardBudgetUsed(), getForwardBudgetLimit(), getNumForwardDropped());
}

void MyMesh::formatPacketStatsReply(char *reply) {
Expand Down
3 changes: 2 additions & 1 deletion examples/simple_sensor/SensorMesh.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -857,7 +857,8 @@ void SensorMesh::formatStatsReply(char *reply) {
}

void SensorMesh::formatRadioStatsReply(char *reply) {
StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime());
StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime(),
getForwardAirTime(), getForwardBudgetUsed(), getForwardBudgetLimit(), getNumForwardDropped());
}

void SensorMesh::formatPacketStatsReply(char *reply) {
Expand Down
2 changes: 2 additions & 0 deletions platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ test_build_src = yes
test_ignore = test_kiss_modem
build_src_filter =
-<*>
+<../src/AirtimeBudget.cpp>
+<../src/Dispatcher.cpp>
+<../src/Utils.cpp>
+<../src/Packet.cpp>
+<../src/helpers/ConfigSerializer.cpp>
Expand Down
68 changes: 68 additions & 0 deletions src/AirtimeBudget.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#include "AirtimeBudget.h"

namespace mesh {

void AirtimeBudget::clear() {
_window_ms = 0;
_slot_ms = 0;
_limit_ms = 0;
_used_ms = 0;
_slot_start = 0;
_slot_count = 0;
_current = 0;
for (uint8_t i = 0; i < MAX_SLOTS; i++) {
_slots[i] = 0;
}
}

void AirtimeBudget::begin(uint32_t window_ms, uint32_t limit_ms, uint32_t now, uint8_t slot_count) {
clear();
if (window_ms == 0 || slot_count == 0) return; // ledger disabled
if (slot_count > MAX_SLOTS) {
slot_count = MAX_SLOTS;
}

_window_ms = window_ms;
_limit_ms = limit_ms;
_slot_count = slot_count;
_slot_ms = (window_ms + slot_count - 1) / slot_count; // round up, so the slots cover the whole window
_slot_start = now;
}

void AirtimeBudget::update(uint32_t now) {
if (_slot_count == 0) return;

uint32_t elapsed = now - _slot_start; // wraps correctly with millis()
if (elapsed >= _window_ms) { // everything is stale
_used_ms = 0;
_current = 0;
for (uint8_t i = 0; i < _slot_count; i++) {
_slots[i] = 0;
}
_slot_start = now;
return;
}

uint32_t steps = elapsed / _slot_ms;
for (uint32_t s = 0; s < steps; s++) {
_current = (_current + 1) % _slot_count;
_used_ms -= _slots[_current];
_slots[_current] = 0;
}
_slot_start += steps * _slot_ms;
}

bool AirtimeBudget::canSpend(uint32_t airtime_ms, uint32_t now) {
if (!isEnabled()) return true;
update(now);
return _used_ms + airtime_ms <= _limit_ms;
}

void AirtimeBudget::record(uint32_t airtime_ms, uint32_t now) {
if (_slot_count == 0 || airtime_ms == 0) return;
update(now);
_slots[_current] += airtime_ms;
_used_ms += airtime_ms;
}

}
75 changes: 75 additions & 0 deletions src/AirtimeBudget.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#pragma once

#include <stdint.h>
#include <MeshCore.h>

namespace mesh {

/**
* \brief Rolling-window airtime ledger.
*
* Accounts for airtime (milliseconds) spent transmitting, and reports how much
* of the trailing window has been used, so a caller can bound how much of the
* channel it occupies over a moving window (eg. a 1% duty-cycle band).
*
* Airtime is accumulated into fixed-size slots. A slot is only forgotten once
* it lies entirely outside the window, so the reported usage is never lower
* than the true trailing-window total: the ledger errs on the side of caution.
* More slots give a finer (less conservative) figure at the cost of RAM.
*/
class AirtimeBudget {
public:
static const uint8_t MAX_SLOTS = 12;

private:
uint32_t _window_ms;
uint32_t _slot_ms;
uint32_t _limit_ms;
uint32_t _used_ms;
uint32_t _slot_start;
uint32_t _slots[MAX_SLOTS];
uint8_t _slot_count;
uint8_t _current;

void clear();

public:
AirtimeBudget() { clear(); }

/**
* \brief Drop any usage that has fallen outside the window.
* \param now current clock, in milliseconds
*/
void update(uint32_t now);

/**
* \brief (Re)initialise the ledger, eg. when prefs change.
* \param window_ms length of the rolling window
* \param limit_ms maximum airtime allowed per window (0 = no limit)
* \param now current clock, in milliseconds
* \param slot_count slots to divide the window into (1..MAX_SLOTS)
*/
void begin(uint32_t window_ms, uint32_t limit_ms, uint32_t now, uint8_t slot_count);

/**
* \returns true if airtime_ms of transmit airtime still fits in the budget.
*/
bool canSpend(uint32_t airtime_ms, uint32_t now);

/**
* \brief Record airtime that was actually spent transmitting.
*/
void record(uint32_t airtime_ms, uint32_t now);

void setLimit(uint32_t limit_ms) { _limit_ms = limit_ms; }

uint32_t getWindow() const { return _window_ms; }
uint32_t getLimit() const { return _limit_ms; }
uint32_t getUsed() const { return _used_ms; }
uint32_t getRemaining() const { return (!isEnabled() || _used_ms >= _limit_ms) ? 0 : _limit_ms - _used_ms; }

/// A budget only binds when it is set below a full window of airtime.
bool isEnabled() const { return _slot_count > 0 && _limit_ms > 0 && _limit_ms < _window_ms; }
};

}
27 changes: 26 additions & 1 deletion src/Dispatcher.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ void Dispatcher::begin() {
tx_budget_ms = (unsigned long)(duty_cycle_window_ms * duty_cycle);
last_budget_update = _ms->getMillis();

float fwd_factor = getForwardAirtimeBudgetFactor();
if (fwd_factor < 0.0f) {
fwd_factor = 0.0f; // treat as no limit
}
float fwd_duty_cycle = 1.0f / (1.0f + fwd_factor);
_fwd_budget.begin(getDutyCycleWindowMs(), (uint32_t)(duty_cycle_window_ms * fwd_duty_cycle), _ms->getMillis(), AirtimeBudget::MAX_SLOTS);

_radio->begin();
prev_isrecv_mode = _radio->isInRecvMode();
}
Expand All @@ -35,6 +42,10 @@ float Dispatcher::getAirtimeBudgetFactor() const {
return 1.0;
}

float Dispatcher::getForwardAirtimeBudgetFactor() const {
return 0; // by default, no separate budget for forwarded traffic
}

void Dispatcher::updateTxBudget() {
unsigned long now = _ms->getMillis();
unsigned long elapsed = now - last_budget_update;
Expand Down Expand Up @@ -89,6 +100,11 @@ void Dispatcher::loop() {
total_air_time += t;
//Serial.print(" airtime="); Serial.println(t);

if (outbound->_forwarded) { // airtime we spent relaying another node's traffic
fwd_air_time += t;
_fwd_budget.record(t, _ms->getMillis());
}

updateTxBudget();

if (t > tx_budget_ms) {
Expand Down Expand Up @@ -268,6 +284,14 @@ void Dispatcher::processRecvPacket(Packet* pkt) {
uint8_t priority = (action >> 24) - 1;
uint32_t _delay = action & 0xFFFFFF;

uint32_t air_time = _radio->getEstAirtimeFor(pkt->getRawLength());
if (!_fwd_budget.canSpend(air_time, _ms->getMillis())) { // relay budget spent: don't queue it at all
n_fwd_dropped++;
MESH_DEBUG_PRINTLN("%s Dispatcher::processRecvPacket(): forward refused by relay airtime budget", getLogDateTime());
_mgr->free(pkt);
return;
}
pkt->_forwarded = true;
_mgr->queueOutbound(pkt, priority, futureMillis(_delay));
}
}
Expand Down Expand Up @@ -373,6 +397,7 @@ void Dispatcher::sendPacket(Packet* packet, uint8_t priority, uint32_t delay_mil
MESH_DEBUG_PRINTLN("%s Dispatcher::sendPacket(): ERROR: invalid packet... path_len=%d, payload_len=%d", getLogDateTime(), (uint32_t) packet->path_len, (uint32_t) packet->payload_len);
_mgr->free(packet);
} else {
packet->_forwarded = false; // this node's own traffic, not a relay
_mgr->queueOutbound(packet, priority, futureMillis(delay_millis));
}
}
Expand All @@ -387,4 +412,4 @@ unsigned long Dispatcher::futureMillis(int millis_from_now) const {
return _ms->getMillis() + millis_from_now;
}

}
}
Loading