diff --git a/zephcore/adapters/datastore/ZephyrDataStore.cpp b/zephcore/adapters/datastore/ZephyrDataStore.cpp index e03269c..37b6f05 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.cpp +++ b/zephcore/adapters/datastore/ZephyrDataStore.cpp @@ -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)); diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index 5fac529..69c3783 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -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 diff --git a/zephcore/app/RepeaterMesh.h b/zephcore/app/RepeaterMesh.h index f0a05dd..e8b2abc 100644 --- a/zephcore/app/RepeaterMesh.h +++ b/zephcore/app/RepeaterMesh.h @@ -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; diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index 6276369..9fcccc4 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -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); diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index 1a0d157..4dfda3c 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -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 diff --git a/zephcore/include/mesh/Dispatcher.h b/zephcore/include/mesh/Dispatcher.h index 5122cca..479a83b 100644 --- a/zephcore/include/mesh/Dispatcher.h +++ b/zephcore/include/mesh/Dispatcher.h @@ -1,158 +1,127 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * ZephCore Dispatcher - packet queue and radio scheduling - */ - -#pragma once - -#include -#include -#include -#include -#include -#include -#include - -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 +#include +#include +#include +#include +#include +#include + +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 */ diff --git a/zephcore/src/Dispatcher.cpp b/zephcore/src/Dispatcher.cpp index 9cfebfc..83c48a0 100644 --- a/zephcore/src/Dispatcher.cpp +++ b/zephcore/src/Dispatcher.cpp @@ -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; diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index 6c3237c..fb47093 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -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 */