Files
ZephCore/zephcore/src/Dispatcher.cpp
T
2026-09-03 22:47:26 +02:00

746 lines
26 KiB
C++

/*
* SPDX-License-Identifier: MIT
* ZephCore Dispatcher implementation
*/
#include <mesh/Dispatcher.h>
#include <mesh/MeshCore.h>
#include <mesh/Utils.h>
#include <string.h>
#include <zephyr/logging/log.h>
#include <zephyr/random/random.h>
LOG_MODULE_REGISTER(zephcore_dispatcher, CONFIG_ZEPHCORE_LORA_LOG_LEVEL);
#if IS_ENABLED(CONFIG_ZEPHCORE_PACKET_LOGGING)
#define PAYLOAD_TYPE_REQ 0x00
#define PAYLOAD_TYPE_RESPONSE 0x01
#define PAYLOAD_TYPE_TXT_MSG 0x02
#define PAYLOAD_TYPE_PATH 0x08
#endif
namespace mesh {
#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)
{
outbound = nullptr;
outbound_priority = 0;
total_air_time = rx_air_time = 0;
next_tx_time = 0;
cad_busy_start = 0;
lbt_busy_start = 0;
lbt_next_warn = 0;
tx_budget_ms = 0;
last_budget_update = 0;
duty_cycle_window_ms = 0;
_err_flags = 0;
radio_nonrx_start = 0;
prev_isrecv_mode = true;
cad_offset_shadow = 0;
cad_offset_shadow_valid = false;
n_sent_flood = n_sent_direct = 0;
n_recv_flood = n_recv_direct = 0;
_tx_queued_cb = nullptr;
_tx_queued_user_data = nullptr;
_wake_cb = nullptr;
_wake_user_data = nullptr;
}
void Dispatcher::begin()
{
n_sent_flood = n_sent_direct = 0;
n_recv_flood = n_recv_direct = 0;
_err_flags = 0;
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();
}
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();
return t == PAYLOAD_TYPE_REQ || t == PAYLOAD_TYPE_RESPONSE ||
t == PAYLOAD_TYPE_ANON_REQ || t == PAYLOAD_TYPE_CONTROL;
}
uint32_t Dispatcher::getCADFailRetryDelay() const
{
/* 100-200ms jittered retry: tighter than one SF8 flood airtime so we
* sample multiple RX duty-cycle windows, and randomized so two nodes
* contending on the same channel don't retry in lockstep. */
return 100 + (sys_rand32_get() % 101);
}
uint32_t Dispatcher::getCADFailMaxDuration() const
{
return 4000; /* ms; ~20 retry attempts before giving up */
}
uint32_t Dispatcher::getTxStarvationDuration() const
{
return 60000; /* ms; 15 warning periods of not transmitting at all */
}
void Dispatcher::loop()
{
if (outbound) {
if (_radio->isSendComplete()) {
/* Airtime is the modulation time of the packet that just
* went out, not the wall-clock width of the send.
*
* Upstream measures the wall clock here and gets away with
* it: on Arduino the CAD runs before outbound_start is
* stamped, loop() polls continuously, and isSendComplete()
* has no timeout, so almost nothing sits between the stamp
* and the TX_DONE interrupt. Our send has all three —
* blocking LBT inside startSendRaw(), a wait-thread
* watchdog, and event-driven completion — so the same
* expression measured up to 8x the real airtime in the
* field, and charged every millisecond of it to the
* duty-cycle budget below.
*
* LoRa airtime is exact given SF/BW/CR/preamble/length, so
* compute it rather than time it: same value the RX side
* already accumulates, which makes the two figures on the
* stats screen comparable for the first time, and the right
* unit for tx_budget_ms, which is a transmitter-on-time
* allowance (CAD is receiving, not transmitting). */
uint32_t t = _radio->getEstAirtimeFor(outbound->getRawLength());
LOG_DBG("TX complete: air=%ums wall=%ums", t,
(uint32_t)_ms->getMillis() - outbound_start);
total_air_time += t;
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()) {
n_sent_flood++;
} else {
n_sent_direct++;
}
releasePacket(outbound);
outbound = nullptr;
} else if (millisHasNowPassed(outbound_expiry)) {
_radio->onSendFinished();
logTxFail(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len);
releasePacket(outbound);
outbound = nullptr;
} else {
return;
}
}
checkRecv();
checkSend();
}
void Dispatcher::maintenanceLoop()
{
_radio->triggerNoiseFloorCalibrate(getInterferenceThreshold());
/* RX mode watchdog: TX counts as "active" to avoid false triggers when
* a maintenance pass lands between brief RX windows. Diagnostic only —
* it raises a status bit and recovers nothing — but that bit is surfaced
* on every role: the repeater/room-server "stats" CLI reply and binary
* telemetry read _err_flags directly, the MQTT uplink publishes it, and
* the companion returns it in its BLE device-status response. */
/* isTxActive(), not !isSendComplete(): the latter is now a one-shot that
* consumes the completion, so asking it here would swallow the event the
* dispatcher's own loop() is waiting to collect. The value is identical
* in every state — it is the same _tx_active read this line always
* performed — so the spurious-STARTRX_TIMEOUT fix this term was added
* for (rapid consecutive relays leaving radio_nonrx_start stale) is
* unchanged. */
bool is_active = _radio->isInRecvMode() || _radio->isTxActive();
if (is_active != prev_isrecv_mode) {
prev_isrecv_mode = is_active;
if (!is_active) {
radio_nonrx_start = (uint32_t)_ms->getMillis();
}
}
if (!is_active &&
(uint32_t)_ms->getMillis() - radio_nonrx_start > RADIO_STALL_THRESHOLD_MS) {
_err_flags |= ERR_EVENT_STARTRX_TIMEOUT;
}
/* Adaptive CAD: probe scheduling + staircase live in the radio;
* we only surface offset changes so the app layer can persist them. */
_radio->cadMaintenance();
/* Receiver hygiene: deaf-aware AGC unstick + temperature-drift
* recalibration. Both sleep the chip, so they live here rather than on
* any packet path. */
_radio->radioMaintenance();
int8_t cad_off = _radio->getCadOffset();
if (!cad_offset_shadow_valid) {
cad_offset_shadow = cad_off;
cad_offset_shadow_valid = true;
} else if (cad_off != cad_offset_shadow) {
cad_offset_shadow = cad_off;
onCadOffsetChanged(cad_off);
}
}
uint32_t Dispatcher::msUntilNextMaintenance()
{
uint32_t now = (uint32_t)_ms->getMillis();
/* Noise floor sampling + CAD probing/decay both live in the radio and
* carry their own deadlines. */
uint32_t next = _radio->msUntilNextMaintenance();
/* Radio stall watchdog. Only pending while the radio is known to be
* neither receiving nor transmitting as of the last pass — the
* transition into that state is itself event-driven (TX start, RX done,
* CAD), so there is nothing to poll for while the radio is active.
*
* The already-flagged check is load-bearing: the verdict is a latched
* status bit, so once raised its deadline sits permanently in the past.
* Without this the query would return 0 on every call and the event loop
* would re-arm at its minimum interval forever. */
if (!prev_isrecv_mode && !(_err_flags & ERR_EVENT_STARTRX_TIMEOUT)) {
next = maintenanceSooner(
next, maintenanceUntil(now, radio_nonrx_start +
RADIO_STALL_THRESHOLD_MS));
}
return next;
}
bool Dispatcher::tryParsePacket(Packet *pkt, const uint8_t *raw, int len)
{
int i = 0;
pkt->header = raw[i++];
if (pkt->getPayloadVer() > PAYLOAD_VER_1) {
LOG_WRN("tryParsePacket: unsupported packet version");
return false;
}
if (pkt->hasTransportCodes()) {
memcpy(&pkt->transport_codes[0], &raw[i], 2); i += 2;
memcpy(&pkt->transport_codes[1], &raw[i], 2); i += 2;
} else {
pkt->transport_codes[0] = pkt->transport_codes[1] = 0;
}
pkt->path_len = raw[i++];
uint8_t path_mode = pkt->path_len >> 6;
if (path_mode == 3) { /* reserved path mode */
LOG_WRN("tryParsePacket: unsupported path mode: 3");
return false;
}
uint8_t path_byte_len = (pkt->path_len & 63) * pkt->getPathHashSize();
if (path_byte_len > MAX_PATH_SIZE || i + path_byte_len > len) {
LOG_WRN("tryParsePacket: partial or corrupt packet, len=%d", len);
return false;
}
memcpy(pkt->path, &raw[i], path_byte_len); i += path_byte_len;
pkt->payload_len = len - i;
if (pkt->payload_len > (int)sizeof(pkt->payload)) {
LOG_WRN("tryParsePacket: payload too big, payload_len=%d", (uint32_t)pkt->payload_len);
return false;
}
memcpy(pkt->payload, &raw[i], pkt->payload_len);
return true;
}
void Dispatcher::checkRecv()
{
/* k_event is a bitfield — multiple ISR arrivals coalesce into one
* wake, so drain the entire ring each time. */
for (;;) {
uint8_t raw[MAX_TRANS_UNIT + 1];
int len = _radio->recvRaw(raw, MAX_TRANS_UNIT);
if (len <= 0) {
break;
}
logRxRaw(_radio->getLastSNR(), _radio->getLastRSSI(), raw, len);
Packet *pkt = _mgr->allocNew();
if (pkt == nullptr) {
LOG_ERR("checkRecv: packet alloc failed");
break;
}
float score = 0.0f;
uint32_t air_time = 0;
if (tryParsePacket(pkt, raw, len)) {
pkt->_snr = (int8_t)(_radio->getLastSNR() * 4.0f); /* x4 fixed-point SNR */
score = _radio->packetScore(_radio->getLastSNR(), len);
air_time = _radio->getEstAirtimeFor(len);
rx_air_time += air_time;
} else {
_mgr->free(pkt);
continue;
}
#if IS_ENABLED(CONFIG_ZEPHCORE_PACKET_LOGGING)
/* Arduino-compatible packet logging - use printk to bypass log level filtering */
{
static uint8_t packet_hash[MAX_HASH_SIZE];
static char hash_hex[MAX_HASH_SIZE * 2 + 1];
pkt->calculatePacketHash(packet_hash);
Utils::toHex(hash_hex, packet_hash, MAX_HASH_SIZE);
uint8_t ptype = pkt->getPayloadType();
if (ptype == PAYLOAD_TYPE_PATH || ptype == PAYLOAD_TYPE_REQ ||
ptype == PAYLOAD_TYPE_RESPONSE || ptype == PAYLOAD_TYPE_TXT_MSG) {
printk("%s: RX, len=%d (type=%d, route=%s, payload_len=%d) SNR=%d RSSI=%d score=%d time=%u hash=%s [%02X -> %02X]\n",
getLogDateTime(), pkt->getRawLength(), ptype,
pkt->isRouteDirect() ? "D" : "F", pkt->payload_len,
(int)pkt->getSNR(), (int)_radio->getLastRSSI(),
(int)(score * 1000), air_time, hash_hex,
(uint32_t)pkt->payload[1], (uint32_t)pkt->payload[0]);
} else {
printk("%s: RX, len=%d (type=%d, route=%s, payload_len=%d) SNR=%d RSSI=%d score=%d time=%u hash=%s\n",
getLogDateTime(), pkt->getRawLength(), ptype,
pkt->isRouteDirect() ? "D" : "F", pkt->payload_len,
(int)pkt->getSNR(), (int)_radio->getLastRSSI(),
(int)(score * 1000), air_time, hash_hex);
}
}
#endif
logRx(pkt, pkt->getRawLength(), score);
if (pkt->isRouteFlood()) {
n_recv_flood++;
} else {
n_recv_direct++;
}
processRecvPacket(pkt);
}
}
void Dispatcher::processRecvPacket(Packet *pkt)
{
DispatcherAction action = onRecvPacket(pkt);
if (action == ACTION_RELEASE) {
_mgr->free(pkt);
} else if (action == ACTION_MANUAL_HOLD) {
/* subclass holds packet */
} else {
uint8_t priority = (uint8_t)((action >> 24) - 1);
uint32_t delay = action & 0xFFFFFF;
_mgr->queueOutbound(pkt, priority, futureMillis((int)delay));
if (_tx_queued_cb && delay > 0) {
_tx_queued_cb(delay, _tx_queued_user_data);
}
}
}
void Dispatcher::checkSend()
{
uint32_t now = (uint32_t)_ms->getMillis();
int count = _mgr->getOutboundCount(now);
if (count == 0) {
cad_busy_start = 0;
/* Only a genuinely EMPTY queue ends an LBT starvation streak, not
* one that merely has nothing due this instant. getOutboundCount()
* excludes packets scheduled in the future, and an LBT refusal
* re-queues its packet 100-200 ms ahead — so every checkSend() that
* lands in that retry gap (any RX wake will do) used to reset the
* streak here, and a node refusing every transmit could keep
* restarting the clock instead of ever reaching the escalation. */
if (_mgr->getOutboundTotal() == 0) {
lbt_busy_start = 0;
}
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;
}
}
bool is_receiving = _radio->isReceiving();
bool is_radio_ready = _radio->isRadioReady();
if (is_receiving || !is_radio_ready) {
/* Channel busy or radio not command-ready — enforce retry timer
* so we don't hammer checks during RX activity or BUSY windows. */
if (!millisHasNowPassed(next_tx_time)) {
if (_tx_queued_cb) {
uint32_t remaining = next_tx_time - now;
_tx_queued_cb(remaining + 1, _tx_queued_user_data);
}
return;
}
if (cad_busy_start == 0) {
cad_busy_start = now;
}
if (now - cad_busy_start > getCADFailMaxDuration()) {
_err_flags |= ERR_EVENT_CAD_TIMEOUT;
LOG_ERR("checkSend: CAD timeout exceeded (isReceiving=%d, isRadioReady=%d, inRecvMode=%d, rssi=%.1f, snr=%.1f, noise=%d, rx_ok=%u, rx_err=%u)",
(int)is_receiving, (int)is_radio_ready, (int)_radio->isInRecvMode(),
(double)_radio->getLastRSSI(), (double)_radio->getLastSNR(),
_radio->getNoiseFloor(),
(unsigned)_radio->getPacketsRecv(),
(unsigned)_radio->getPacketsRecvErrors());
/* Channel activity has gone on too long -- the radio may be
* in a bad state. FORCE the pending transmit by falling
* through, exactly as Arduino MeshCore does
* (Dispatcher.cpp: "force the pending transmit below...").
*
* This bounded give-up was ZephCore's behaviour too until
* 3441caf "new rx busy latch" added a `return` here, turning a
* 4 s hard limit into an unbounded defer: on a channel that
* reads busy forever the node never transmits again, silently
* filling the 32-entry outbound queue until queueOutbound()
* starts evicting and dropping.
*
* recoverRxState() is kept and runs first: the non-destructive
* sx126x_is_receiving() has no side-effect IRQ clear, so a stuck
* preamble bit needs the chip walked REST -> fresh RX. Doing it
* before the forced TX leaves the receiver healthy afterwards;
* send_async accepts the RX -> TX entry CAS. */
_radio->recoverRxState();
/* fall through -- force the pending transmit */
} else {
uint32_t retry = getCADFailRetryDelay();
next_tx_time = futureMillis((int)retry);
if (_tx_queued_cb) {
_tx_queued_cb(retry + 1, _tx_queued_user_data);
}
return;
}
}
cad_busy_start = 0;
/* Snapshot the priority of the packet we're about to dequeue so it
* can be preserved if the send attempt fails and we need to re-queue.
* Must be called before getNextOutbound() removes the entry. */
outbound_priority = _mgr->peekNextOutboundPriority(now);
outbound = _mgr->getNextOutbound(now);
if (outbound) {
uint8_t raw[MAX_TRANS_UNIT];
int len = 0;
raw[len++] = outbound->header;
if (outbound->hasTransportCodes()) {
memcpy(&raw[len], &outbound->transport_codes[0], 2); len += 2;
memcpy(&raw[len], &outbound->transport_codes[1], 2); len += 2;
}
raw[len++] = outbound->path_len;
/* Trusted source: outbound->path is MAX_PATH_SIZE-sized. */
len += Packet::writePath(&raw[len], outbound->path, MAX_PATH_SIZE, outbound->path_len);
if (len + outbound->payload_len > MAX_TRANS_UNIT) {
LOG_ERR("checkSend: packet too large len=%d+%d > %d", len, outbound->payload_len, MAX_TRANS_UNIT);
_mgr->free(outbound);
outbound = nullptr;
} else {
memcpy(&raw[len], outbound->payload, outbound->payload_len);
len += outbound->payload_len;
uint32_t max_airtime = _radio->getEstAirtimeFor(len) * 3 / 2;
/* Short packets (ACKs) have est airtimes small enough that
* IRQ/work-queue latency alone can blow the watchdog and clip
* the TX mid-air (upstream 4f8cb8db: 200ms est floor, x1.5). */
if (max_airtime < 300) {
max_airtime = 300;
}
outbound_start = now;
#if IS_ENABLED(CONFIG_ZEPHCORE_PACKET_LOGGING)
/* Arduino-compatible packet logging - use printk to bypass log level filtering */
{
uint8_t ptype = outbound->getPayloadType();
if (ptype == PAYLOAD_TYPE_PATH || ptype == PAYLOAD_TYPE_REQ ||
ptype == PAYLOAD_TYPE_RESPONSE || ptype == PAYLOAD_TYPE_TXT_MSG) {
printk("%s: TX, len=%d (type=%d, route=%s, payload_len=%d) [%02X -> %02X]\n",
getLogDateTime(), len, ptype,
outbound->isRouteDirect() ? "D" : "F", outbound->payload_len,
(uint32_t)outbound->payload[1], (uint32_t)outbound->payload[0]);
} else {
printk("%s: TX, len=%d (type=%d, route=%s, payload_len=%d)\n",
getLogDateTime(), len, ptype,
outbound->isRouteDirect() ? "D" : "F", outbound->payload_len);
}
}
#endif
/* Final gate — close the gap between initial checks and
* actual TX start (serialisation + logging can take 1-5 ms). */
bool final_is_receiving = _radio->isReceiving();
bool final_is_radio_ready = _radio->isRadioReady();
/* isTxActive() covers the window the radio opened by
* publishing its completion before it finishes re-arming
* RX: we may have collected that completion and come
* straight back here. startSendRaw()'s CAS would refuse
* anyway, but that refusal is reported as an LBT-busy
* verdict and feeds the cad_busy_start escalation, which
* this is not — "radio not ready yet" belongs here. */
if (final_is_receiving || !final_is_radio_ready ||
_radio->isTxActive()) {
uint32_t retry = getCADFailRetryDelay();
LOG_DBG("checkSend: final gate blocked TX (isReceiving=%d, isRadioReady=%d, inRecvMode=%d, txActive=%d)",
(int)final_is_receiving, (int)final_is_radio_ready,
(int)_radio->isInRecvMode(),
(int)_radio->isTxActive());
_mgr->queueOutbound(outbound, outbound_priority, futureMillis((int)retry));
outbound = nullptr;
if (_tx_queued_cb) {
_tx_queued_cb(retry, _tx_queued_user_data);
}
return;
}
bool success = _radio->startSendRaw(raw, len);
if (!success) {
uint32_t retry = getCADFailRetryDelay();
/* Almost always LBT refusing a busy channel, which is the
* designed outcome — the packet is re-queued below and
* retried, and we deliberately do NOT force a transmit
* here: unlike the isReceiving() gate above, where a long
* refusal suggests a stuck radio, a busy LBT verdict is a
* TRUE reading of the channel. Forcing through it would
* transmit into traffic the radio can hear — exactly the
* collision CAD exists to prevent, at the moment the
* channel is most contended.
*
* INF, not DBG: this used to be ERR, which buried real
* faults on a busy site, and was then dropped to DBG —
* which made a node refusing every transmit completely
* invisible at default log level. A node that is not
* transmitting should say so; INF reports the refusals
* themselves rather than inferring a stall from them. */
LOG_INF("checkSend: startSendRaw refused (LBT busy), re-queuing delay=%u", retry);
/* Escalate a refusal that will not end. This branch
* used to leave cad_busy_start alone, so a packet the
* driver's own LBT kept rejecting — the pre-TX gate
* having passed — looped indefinitely with no counter,
* no error flag and nothing above DBG. At default log
* level a node in that state looks completely idle
* while it never transmits, which is exactly how a
* chip-side CAD (LR20xx CAD_LBT) fails.
*
* Deliberately no recoverRxState() here, unlike the
* isReceiving() branch above: that one recovers a chip
* suspected of being stuck in RX, whereas a busy
* channel is a true reading and walking the radio
* through REST would only add deaf time. Report and
* keep retrying. */
if (lbt_busy_start == 0) {
lbt_busy_start = now;
lbt_next_warn = now + getCADFailMaxDuration();
} else {
uint32_t streak = now - lbt_busy_start;
if ((int32_t)(now - lbt_next_warn) >= 0) {
_err_flags |= ERR_EVENT_CAD_TIMEOUT;
LOG_WRN("checkSend: LBT has refused TX for %ums "
"(len=%d, noise=%d) — channel busy or CAD too sensitive",
(unsigned)streak, len,
_radio->getNoiseFloor());
lbt_next_warn = now + getCADFailMaxDuration();
}
/* Self-unmute. A node whose LBT has refused EVERY
* transmit for this long is not looking at a busy
* channel — a busy channel still yields gaps, and any
* success resets this streak. It is looking at a
* detector tuned too sensitive to ever clear, which
* `set cad.offset -8` on a quiet site produces
* outright.
*
* The adaptive staircase cannot be relied on to undo
* that: it runs only when `cad.auto` is on, only when
* `cad.busycap` is non-zero, and only once the
* operating level has 120 probes behind it — half an
* hour at best. All three are settings a hand-tuning
* operator turns off, and the CLI help for
* `set cad.auto` recommends exactly that workflow. A
* repeater on a mast that stops transmitting cannot
* be talked back down either: it still receives and
* still applies an admin `set`, but the reply never
* gets out, so no ordinary app completes the login it
* is waiting on.
*
* So this deliberately overrides `cad.auto` and runs
* regardless of the operator's settings. The radio
* clamps to its own least-sensitive step and reports
* when it can go no further.
*
* The new offset PERSISTS, like a staircase step:
* maintenanceLoop() notices getCadOffset() moved and
* calls onCadOffsetChanged(), which writes prefs.
* That is the behaviour we want on a mast — a node
* that healed itself must not go mute again on the
* next reboot — and it is what `cad.auto` already
* does to a hand-set offset. Writes are bounded: each
* step needs another full starvation period, and they
* stop the moment a transmit succeeds. */
if (streak > getTxStarvationDuration()) {
if (_radio->cadRelaxOnTxStarvation()) {
LOG_ERR("checkSend: TX starved %ums — relaxed CAD one step",
(unsigned)streak);
} else {
LOG_ERR("checkSend: TX starved %ums — CAD already at its "
"least sensitive step, channel may be genuinely busy",
(unsigned)streak);
}
lbt_busy_start = now;
lbt_next_warn = now + getCADFailMaxDuration();
}
}
logTxFail(outbound, outbound->getRawLength());
_mgr->queueOutbound(outbound, outbound_priority, futureMillis((int)retry));
outbound = nullptr;
if (_tx_queued_cb) {
_tx_queued_cb(retry, _tx_queued_user_data);
}
} else {
outbound_expiry = futureMillis((int)max_airtime);
/* A transmit got out, so the refusal streak above is
* over. Without this the timer keeps running across
* successful sends and the next isolated refusal
* inherits a stale start, reporting a stall that
* already ended. */
cad_busy_start = 0;
lbt_busy_start = 0;
}
}
}
}
Packet *Dispatcher::obtainNewPacket()
{
Packet *pkt = _mgr->allocNew();
if (pkt == nullptr) {
_err_flags |= ERR_EVENT_FULL;
} else {
pkt->payload_len = pkt->path_len = 0;
pkt->_snr = 0;
}
return pkt;
}
void Dispatcher::releasePacket(Packet *packet)
{
_mgr->free(packet);
}
void Dispatcher::sendPacket(Packet *packet, uint8_t priority, uint32_t delay_millis)
{
if (!Packet::isValidPathLen(packet->path_len) || packet->payload_len > MAX_PACKET_PAYLOAD) {
LOG_ERR("sendPacket: rejected - path_len=%d or payload_len=%d invalid",
packet->path_len, packet->payload_len);
_mgr->free(packet);
} else {
_mgr->queueOutbound(packet, priority, futureMillis((int)delay_millis));
/* Fire the wake callback even for delay_millis == 0. Companion
* BLE/USB-driven direct & zero-hop sends enqueue with delay 0 from
* sysworkq — off the main loop — and the per-frame RX wake was
* removed in 57b971f, so without this they have no drain signal
* (USB companion has no tx-idle backstop). The callback reschedules
* tx_drain_work with K_MSEC(0) → immediate drain; redundant but
* harmless when sendPacket is already called from the main loop. */
if (_tx_queued_cb) {
_tx_queued_cb(delay_millis, _tx_queued_user_data);
}
}
}
bool Dispatcher::millisHasNowPassed(uint32_t timestamp) const
{
return (int32_t)((uint32_t)_ms->getMillis() - timestamp) > 0;
}
uint32_t Dispatcher::futureMillis(int millis_from_now) const
{
return (uint32_t)_ms->getMillis() + millis_from_now;
}
} /* namespace mesh */