port vanilla duty cycling logic

This commit is contained in:
liquidraver committed 2026-04-28 14:43:12 +02:00
1 parent b57df41753
commit 1092c49a7d
8 files changed
+227 -188

No files matched your search

@@ -391,10 +391,6 @@ void ZephyrDataStore::loadPrefs(NodePrefs &prefs)
size_t off = 0;
memcpy(&prefs.airtime_factor, &buf[off], sizeof(float));
off += 4;
/* Migrate old AF multiplier (0-9) to duty cycle percentage (0-99) */
if (prefs.airtime_factor > 0.0f && prefs.airtime_factor <= 9.0f) {
prefs.airtime_factor *= 10.0f;
}
memcpy(prefs.node_name, &buf[off], 32);
off += 36; /* 32 name + 4 pad */
memcpy(&prefs.node_lat, &buf[off], sizeof(double));
+2 -1
View File
@@ -1228,7 +1228,8 @@ uint32_t CompanionMesh::getInitialFloodJitter(const mesh::Packet *packet)
uint8_t CompanionMesh::getDutyCyclePercent() const
{
return (uint8_t)prefs.airtime_factor;
/* Arduino formula: duty% = 100 / (af + 1). af=0 → 100%, af=9 → 10%. */
return (uint8_t)(100.0f / (prefs.airtime_factor + 1.0f) + 0.5f);
}
uint8_t CompanionMesh::getExtraAckTransmitCount() const
+2 -1
View File
@@ -134,7 +134,8 @@ class RepeaterMesh : public mesh::Mesh, public CommonCLICallbacks {
protected:
uint8_t getDutyCyclePercent() const override {
return (uint8_t)_prefs.airtime_factor;
/* Arduino formula: duty% = 100 / (af + 1). af=0 → 100%, af=9 → 10%. */
return (uint8_t)(100.0f / (_prefs.airtime_factor + 1.0f) + 0.5f);
}
bool allowPacketForward(const mesh::Packet* packet) override;
+4 -5
View File
@@ -132,11 +132,10 @@ void CommonCLI::loadPrefs(const char* path) {
_prefs->backoff_multiplier = 0.2f;
}
_prefs->backoff_multiplier = constrain(_prefs->backoff_multiplier, 0.0f, 2.0f);
/* Migrate old AF multiplier (0-9) to duty cycle percentage (0-99) */
if (_prefs->airtime_factor > 0.0f && _prefs->airtime_factor <= 9.0f) {
_prefs->airtime_factor *= 10.0f;
}
_prefs->airtime_factor = constrain(_prefs->airtime_factor, 0.0f, 99.0f);
/* af is the Arduino airtime budget factor: duty% = 100 / (af + 1).
* Range matches upstream (0..9). Values >9 (from a previous build that
* stored af as a percentage) get clamped to 9 → 10% effective. */
_prefs->airtime_factor = constrain(_prefs->airtime_factor, 0.0f, 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, (uint8_t)5, (uint8_t)12);
+1 -1
View File
@@ -83,7 +83,7 @@ struct NodePrefs {
/* Default prefs -- must match LoRaConfig.h defaults for radio interop. */
static inline void initNodePrefs(NodePrefs* prefs) {
memset(prefs, 0, sizeof(NodePrefs));
prefs->airtime_factor = 10.0f; /* 10% duty cycle */
prefs->airtime_factor = 9.0f; /* Arduino formula: duty% = 100 / (af + 1) → 10% */
prefs->node_lat = 0.0;
prefs->node_lon = 0.0;
#ifdef CONFIG_ZEPHCORE_ADMIN_PASSWORD
+127 -158
View File
@@ -1,158 +1,127 @@
/*
* SPDX-License-Identifier: Apache-2.0
* ZephCore Dispatcher - packet queue and radio scheduling
*/
#pragma once
#include <mesh/MeshCore.h>
#include <mesh/Identity.h>
#include <mesh/Packet.h>
#include <mesh/Utils.h>
#include <mesh/Radio.h>
#include <mesh/Clock.h>
#include <string.h>
namespace mesh {
/* EU ETSI EN 300 220 duty cycle tracker.
* Fixed 1-hour window, tracks cumulative TX airtime in ms.
* duty_pct=0 disables all tracking (zero overhead). */
struct DutyCycleTracker {
uint32_t window_start;
uint32_t window_airtime_ms;
uint8_t duty_pct; /* 0=disabled, 1-99=percentage */
void init(uint8_t pct) {
window_start = 0;
window_airtime_ms = 0;
duty_pct = pct;
}
void recordTx(uint32_t duration_ms, uint32_t now) {
if (duty_pct == 0) return;
if (now - window_start > 3600000UL) { /* 1 hour window */
window_start = now;
window_airtime_ms = 0;
}
window_airtime_ms += duration_ms;
}
bool isExceeded(uint32_t now) const {
if (duty_pct == 0) return false;
if (now - window_start > 3600000UL) return false; /* 1h window expired */
uint32_t budget_ms = (3600000UL / 100) * (uint32_t)duty_pct; /* ms per 1% of 1h */
return window_airtime_ms >= budget_ms;
}
uint32_t budgetMs() const {
if (duty_pct == 0) return 0;
return (3600000UL / 100) * (uint32_t)duty_pct; /* ms per 1% of 1h */
}
};
class PacketManager {
public:
virtual Packet *allocNew() = 0;
virtual void free(Packet *packet) = 0;
virtual void queueOutbound(Packet *packet, uint8_t priority, uint32_t scheduled_for) = 0;
virtual Packet *getNextOutbound(uint32_t now) = 0;
virtual int getOutboundCount(uint32_t now) const = 0;
virtual int getOutboundTotal() const = 0;
virtual int getFreeCount() const = 0;
virtual Packet *getOutboundByIdx(int i) = 0;
virtual Packet *removeOutboundByIdx(int i) = 0;
virtual uint32_t getOutboundSchedule(int i) const = 0;
virtual bool rescheduleOutbound(int i, uint32_t new_scheduled_for) = 0;
virtual void queueInbound(Packet *packet, uint32_t scheduled_for) = 0;
virtual Packet *getNextInbound(uint32_t now) = 0;
};
/* Notifies event loop of pending TX so it can schedule a wake. */
typedef void (*tx_queued_callback_t)(uint32_t delay_ms, void *user_data);
typedef uint32_t DispatcherAction;
#define ACTION_RELEASE (0)
#define ACTION_MANUAL_HOLD (1)
#define ACTION_RETRANSMIT(pri) (((uint32_t)1 + (pri))<<24)
#define ACTION_RETRANSMIT_DELAYED(pri, _delay) ((((uint32_t)1 + (pri))<<24) | (_delay))
#define ERR_EVENT_FULL (1 << 0)
#define ERR_EVENT_CAD_TIMEOUT (1 << 1)
#define ERR_EVENT_STARTRX_TIMEOUT (1 << 2)
class Dispatcher {
Packet *outbound;
uint32_t outbound_expiry, outbound_start, total_air_time, rx_air_time;
uint32_t next_tx_time;
uint32_t cad_busy_start;
DutyCycleTracker _duty_cycle;
uint32_t radio_nonrx_start;
uint32_t next_agc_reset_time;
bool prev_isrecv_mode;
uint32_t n_sent_flood, n_sent_direct;
uint32_t n_recv_flood, n_recv_direct;
tx_queued_callback_t _tx_queued_cb;
void *_tx_queued_user_data;
void processRecvPacket(Packet *pkt);
protected:
Radio *_radio;
MillisecondClock *_ms;
PacketManager *_mgr;
uint16_t _err_flags;
Dispatcher(Radio &radio, MillisecondClock &ms, PacketManager &mgr);
void notifyTxQueued(uint32_t delay_ms) {
if (_tx_queued_cb) _tx_queued_cb(delay_ms, _tx_queued_user_data);
}
virtual DispatcherAction onRecvPacket(Packet *pkt) = 0;
virtual void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { (void)snr; (void)rssi; (void)raw; (void)len; }
virtual void logRx(Packet *packet, int len, float score) { (void)packet; (void)len; (void)score; }
virtual void logTx(Packet *packet, int len) { (void)packet; (void)len; }
virtual void logTxFail(Packet *packet, int len) { (void)packet; (void)len; }
virtual const char *getLogDateTime() { return ""; }
virtual uint8_t getDutyCyclePercent() const;
static bool isAdminPacket(const Packet *pkt);
virtual int calcRxDelay(float score, uint32_t air_time) const;
virtual uint32_t getCADFailRetryDelay() const;
virtual uint32_t getCADFailMaxDuration() const;
virtual int getInterferenceThreshold() const { return 0; }
virtual int getAGCResetInterval() const { return 0; }
public:
void begin();
void loop();
void maintenanceLoop();
Packet *obtainNewPacket();
void releasePacket(Packet *packet);
void sendPacket(Packet *packet, uint8_t priority, uint32_t delay_millis = 0);
uint32_t getTotalAirTime() const { return total_air_time; }
uint32_t getReceiveAirTime() const { return rx_air_time; }
uint32_t getNumSentFlood() const { return n_sent_flood; }
uint32_t getNumSentDirect() const { return n_sent_direct; }
uint32_t getNumRecvFlood() const { return n_recv_flood; }
uint32_t getNumRecvDirect() const { return n_recv_direct; }
uint16_t getErrFlags() const { return _err_flags; }
void resetStats() {
n_sent_flood = n_sent_direct = 0;
n_recv_flood = n_recv_direct = 0;
_err_flags = 0;
}
void setTxQueuedCallback(tx_queued_callback_t cb, void *user_data) {
_tx_queued_cb = cb;
_tx_queued_user_data = user_data;
}
bool millisHasNowPassed(uint32_t timestamp) const;
uint32_t futureMillis(int millis_from_now) const;
private:
bool tryParsePacket(Packet *pkt, const uint8_t *raw, int len);
void checkRecv();
void checkSend();
};
} /* namespace mesh */
/*
* SPDX-License-Identifier: Apache-2.0
* ZephCore Dispatcher - packet queue and radio scheduling
*/
#pragma once
#include <mesh/MeshCore.h>
#include <mesh/Identity.h>
#include <mesh/Packet.h>
#include <mesh/Utils.h>
#include <mesh/Radio.h>
#include <mesh/Clock.h>
#include <string.h>
namespace mesh {
class PacketManager {
public:
virtual Packet *allocNew() = 0;
virtual void free(Packet *packet) = 0;
virtual void queueOutbound(Packet *packet, uint8_t priority, uint32_t scheduled_for) = 0;
virtual Packet *getNextOutbound(uint32_t now) = 0;
virtual int getOutboundCount(uint32_t now) const = 0;
virtual int getOutboundTotal() const = 0;
virtual int getFreeCount() const = 0;
virtual Packet *getOutboundByIdx(int i) = 0;
virtual Packet *removeOutboundByIdx(int i) = 0;
virtual uint32_t getOutboundSchedule(int i) const = 0;
virtual bool rescheduleOutbound(int i, uint32_t new_scheduled_for) = 0;
virtual void queueInbound(Packet *packet, uint32_t scheduled_for) = 0;
virtual Packet *getNextInbound(uint32_t now) = 0;
};
/* Notifies event loop of pending TX so it can schedule a wake. */
typedef void (*tx_queued_callback_t)(uint32_t delay_ms, void *user_data);
typedef uint32_t DispatcherAction;
#define ACTION_RELEASE (0)
#define ACTION_MANUAL_HOLD (1)
#define ACTION_RETRANSMIT(pri) (((uint32_t)1 + (pri))<<24)
#define ACTION_RETRANSMIT_DELAYED(pri, _delay) ((((uint32_t)1 + (pri))<<24) | (_delay))
#define ERR_EVENT_FULL (1 << 0)
#define ERR_EVENT_CAD_TIMEOUT (1 << 1)
#define ERR_EVENT_STARTRX_TIMEOUT (1 << 2)
class Dispatcher {
Packet *outbound;
uint32_t outbound_expiry, outbound_start, total_air_time, rx_air_time;
uint32_t next_tx_time;
uint32_t cad_busy_start;
uint32_t tx_budget_ms;
uint32_t last_budget_update;
uint32_t duty_cycle_window_ms;
uint32_t radio_nonrx_start;
uint32_t next_agc_reset_time;
bool prev_isrecv_mode;
uint32_t n_sent_flood, n_sent_direct;
uint32_t n_recv_flood, n_recv_direct;
tx_queued_callback_t _tx_queued_cb;
void *_tx_queued_user_data;
void processRecvPacket(Packet *pkt);
protected:
Radio *_radio;
MillisecondClock *_ms;
PacketManager *_mgr;
uint16_t _err_flags;
Dispatcher(Radio &radio, MillisecondClock &ms, PacketManager &mgr);
void notifyTxQueued(uint32_t delay_ms) {
if (_tx_queued_cb) _tx_queued_cb(delay_ms, _tx_queued_user_data);
}
virtual DispatcherAction onRecvPacket(Packet *pkt) = 0;
virtual void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { (void)snr; (void)rssi; (void)raw; (void)len; }
virtual void logRx(Packet *packet, int len, float score) { (void)packet; (void)len; (void)score; }
virtual void logTx(Packet *packet, int len) { (void)packet; (void)len; }
virtual void logTxFail(Packet *packet, int len) { (void)packet; (void)len; }
virtual const char *getLogDateTime() { return ""; }
virtual uint8_t getDutyCyclePercent() const;
static bool isAdminPacket(const Packet *pkt);
virtual int calcRxDelay(float score, uint32_t air_time) const;
virtual uint32_t getCADFailRetryDelay() const;
virtual uint32_t getCADFailMaxDuration() const;
virtual int getInterferenceThreshold() const { return 0; }
virtual int getAGCResetInterval() const { return 0; }
virtual uint32_t getDutyCycleWindowMs() const { return 3600000UL; } /* 1h default */
public:
void begin();
void loop();
void maintenanceLoop();
Packet *obtainNewPacket();
void releasePacket(Packet *packet);
void sendPacket(Packet *packet, uint8_t priority, uint32_t delay_millis = 0);
uint32_t getTotalAirTime() const { return total_air_time; }
uint32_t getReceiveAirTime() const { return rx_air_time; }
uint32_t getNumSentFlood() const { return n_sent_flood; }
uint32_t getNumSentDirect() const { return n_sent_direct; }
uint32_t getNumRecvFlood() const { return n_recv_flood; }
uint32_t getNumRecvDirect() const { return n_recv_direct; }
uint16_t getErrFlags() const { return _err_flags; }
void resetStats() {
n_sent_flood = n_sent_direct = 0;
n_recv_flood = n_recv_direct = 0;
_err_flags = 0;
}
void setTxQueuedCallback(tx_queued_callback_t cb, void *user_data) {
_tx_queued_cb = cb;
_tx_queued_user_data = user_data;
}
bool millisHasNowPassed(uint32_t timestamp) const;
uint32_t futureMillis(int millis_from_now) const;
private:
void updateTxBudget();
uint32_t getMaxTxBudgetMs() const;
bool tryParsePacket(Packet *pkt, const uint8_t *raw, int len);
void checkRecv();
void checkSend();
};
} /* namespace mesh */
+90 -17
View File
@@ -21,7 +21,8 @@ LOG_MODULE_REGISTER(zephcore_dispatcher, CONFIG_ZEPHCORE_LORA_LOG_LEVEL);
namespace mesh {
#define MAX_RX_DELAY_MILLIS 32000 /* upper bound for score-based RX delay */
#define MAX_RX_DELAY_MILLIS 32000 /* upper bound for score-based RX delay */
#define MIN_TX_BUDGET_AIRTIME_DIV 2 /* require at least 1/N MTU airtime as budget before TX */
Dispatcher::Dispatcher(Radio &radio, MillisecondClock &ms, PacketManager &mgr)
: _radio(&radio), _ms(&ms), _mgr(&mgr)
@@ -30,9 +31,11 @@ Dispatcher::Dispatcher(Radio &radio, MillisecondClock &ms, PacketManager &mgr)
total_air_time = rx_air_time = 0;
next_tx_time = 0;
cad_busy_start = 0;
tx_budget_ms = 0;
last_budget_update = 0;
duty_cycle_window_ms = 0;
next_agc_reset_time = 0;
_err_flags = 0;
_duty_cycle.init(0);
radio_nonrx_start = 0;
prev_isrecv_mode = true;
n_sent_flood = n_sent_direct = 0;
@@ -46,10 +49,14 @@ void Dispatcher::begin()
n_sent_flood = n_sent_direct = 0;
n_recv_flood = n_recv_direct = 0;
_err_flags = 0;
radio_nonrx_start = (uint32_t)_ms->getMillis();
uint32_t now = (uint32_t)_ms->getMillis();
radio_nonrx_start = now;
duty_cycle_window_ms = getDutyCycleWindowMs();
tx_budget_ms = getMaxTxBudgetMs();
last_budget_update = now;
next_tx_time = now;
_radio->begin();
prev_isrecv_mode = _radio->isInRecvMode();
_duty_cycle.init(getDutyCyclePercent());
}
uint8_t Dispatcher::getDutyCyclePercent() const
@@ -57,6 +64,39 @@ uint8_t Dispatcher::getDutyCyclePercent() const
return 10; /* EU 868 default: 10% duty cycle */
}
uint32_t Dispatcher::getMaxTxBudgetMs() const
{
uint8_t duty_pct = getDutyCyclePercent();
if (duty_pct == 0 || duty_cycle_window_ms == 0) {
return 0;
}
return (duty_cycle_window_ms * (uint32_t)duty_pct) / 100U;
}
void Dispatcher::updateTxBudget()
{
uint8_t duty_pct = getDutyCyclePercent();
if (duty_pct == 0 || duty_cycle_window_ms == 0) {
return;
}
uint32_t now = (uint32_t)_ms->getMillis();
uint32_t elapsed = now - last_budget_update;
if (elapsed == 0) {
return;
}
uint32_t refill = (elapsed * (uint32_t)duty_pct) / 100U;
if (refill > 0) {
uint32_t max_budget = getMaxTxBudgetMs();
tx_budget_ms += refill;
if (tx_budget_ms > max_budget) {
tx_budget_ms = max_budget;
}
last_budget_update = now;
}
}
bool Dispatcher::isAdminPacket(const Packet *pkt)
{
uint8_t t = pkt->getPayloadType();
@@ -99,7 +139,12 @@ void Dispatcher::loop()
if (_radio->isSendComplete()) {
uint32_t t = (uint32_t)_ms->getMillis() - outbound_start;
total_air_time += t;
_duty_cycle.recordTx(t, (uint32_t)_ms->getMillis());
updateTxBudget();
if (t >= tx_budget_ms) {
tx_budget_ms = 0;
} else {
tx_budget_ms -= t;
}
_radio->onSendFinished();
logTx(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len);
if (outbound->isRouteFlood()) {
@@ -291,6 +336,46 @@ void Dispatcher::checkSend()
return;
}
/* Duty-cycle budget gate. Matches Arduino MeshCore: defer when remaining
* budget < est_airtime / MIN_TX_BUDGET_AIRTIME_DIV (i.e. half an MTU's airtime).
*
* Divergence from upstream: we exempt admin packets from the gate so that
* remote management (admin requests, login, etc.) keeps working when a node
* has burned its budget. Strictly out-of-spec for EN 300 220 — admin floods
* still consume airtime — but a managed node that can't be reached to be
* disabled is worse than the marginal extra airtime. Scan is O(N) over
* the small (24-32) packet pool so the cost is negligible. */
updateTxBudget();
uint8_t duty_pct = getDutyCyclePercent();
if (duty_pct > 0) {
bool due_admin_queued = false;
int total = _mgr->getOutboundTotal();
for (int i = 0; i < total; i++) {
Packet *pkt = _mgr->getOutboundByIdx(i);
if (!pkt) {
continue;
}
if ((int32_t)(_mgr->getOutboundSchedule(i) - now) > 0) {
continue;
}
if (isAdminPacket(pkt)) {
due_admin_queued = true;
break;
}
}
uint32_t est_airtime = _radio->getEstAirtimeFor(MAX_TRANS_UNIT);
uint32_t threshold = est_airtime / MIN_TX_BUDGET_AIRTIME_DIV;
if (!due_admin_queued && tx_budget_ms < threshold) {
uint32_t needed = threshold - tx_budget_ms;
uint32_t delay_ms = (needed * 100U + (uint32_t)duty_pct - 1U) / (uint32_t)duty_pct;
if (_tx_queued_cb) {
_tx_queued_cb(delay_ms + 1U, _tx_queued_user_data);
}
return;
}
}
if (_radio->isReceiving()) {
/* Channel busy — enforce retry timer so we don't hammer the check */
if (!millisHasNowPassed(next_tx_time)) {
@@ -319,18 +404,6 @@ void Dispatcher::checkSend()
outbound = _mgr->getNextOutbound(now);
if (outbound) {
/* Duty cycle enforcement — exempt admin packets */
if (!isAdminPacket(outbound) && _duty_cycle.isExceeded(now)) {
LOG_WRN("checkSend: duty cycle exceeded (%u/%u ms), re-queuing type=%d",
_duty_cycle.window_airtime_ms, _duty_cycle.budgetMs(),
outbound->getPayloadType());
_mgr->queueOutbound(outbound, 0, futureMillis(5000));
outbound = nullptr;
if (_tx_queued_cb) {
_tx_queued_cb(5000, _tx_queued_user_data);
}
return;
}
uint8_t raw[MAX_TRANS_UNIT];
int len = 0;
raw[len++] = outbound->header;
+1 -1
View File
@@ -532,7 +532,7 @@ int main(void)
companion_mesh.prefs.cr = 8;
companion_mesh.prefs.tx_power_dbm = 22;
companion_mesh.prefs.rx_delay_base = 0.0f; /* Disabled for companion */
companion_mesh.prefs.airtime_factor = 10.0f; /* 10% duty cycle (EU 868 default) */
companion_mesh.prefs.airtime_factor = 9.0f; /* Arduino formula: 100/(af+1) → 10% (EU 868 default) */
companion_mesh.prefs.rx_duty_cycle = 1; /* Companions: duty cycle ON by default (power save) */
companion_mesh.prefs.rx_boost = 1; /* Default: boosted RX (+3dB sensitivity, +2mA) */
companion_mesh.prefs.apc_enabled = 0; /* Default: APC off */