diff --git a/docs/dynamic-coding-rate.md b/docs/dynamic-coding-rate.md new file mode 100644 index 00000000000..88f31ed69de --- /dev/null +++ b/docs/dynamic-coding-rate.md @@ -0,0 +1,86 @@ +# Dynamic Coding Rate + +Dynamic Coding Rate (DCR) lets firmware select LoRa coding rate per packet while leaving the Meshtastic wire packet unchanged. It relies on LoRa explicit header mode: the RF header carries the payload CR, payload length, and CRC flag, so the selected CR is physical-layer metadata and does not belong in `MeshPacket`. + +The implementation is centered on `AirtimePolicy`. Today that policy chooses CR 4/5 through CR 4/8. It is deliberately not owned by telemetry, routing, or an individual radio backend because those paths all compete for the same airtime budget. + +## Configuration + +DCR settings live in `Config.LoRaConfig`: + +- `dcr_mode`: `DCR_OFF` or `DCR_ON`. `DCR_OFF` keeps the configured static coding rate; `DCR_ON` applies + per-packet CR selection. + +Existing `coding_rate` remains the base/static CR. When DCR is off, it is the transmit CR. When DCR is enabled, the radio starts from that base and may choose a different CR immediately before TX. + +The rest of the policy is intentionally tuned in firmware rather than exposed as user configuration. Meshtasticator modeling found that small changes to retry, relay, and CR 4/8 budget rules can improve one scenario while hurting dense collision-heavy cases, so this PR keeps the first public surface to a single opt-in switch. + +## Runtime Flow + +Outgoing decoded packets are classified before encryption in `Router::send()` and remembered in a local side cache. This avoids adding a protobuf field and lets relayed/encrypted packets still use earlier local context when available. + +Immediately before transmit, `RadioInterface::chooseCodingRateForPacket()` builds a context from: + +- packet class and priority +- retry state recorded by `NextHopRouter` +- relay/late-relay/last-hop context +- channel utilization, TX utilization, queue depth, and duty-cycle pressure +- predicted airtime for each candidate CR + +The selected CR is applied through `setActiveCodingRate()`, transmission starts, and the backend restores the base CR after TX completion or TX-start failure. + +Received RadioLib packets call `getLoRaRxHeaderInfo()` during airtime calculation. When `DCR_ON` is enabled, the normalized RX CR is then passed to `AirtimePolicy::observeRx()` for counters and local neighbor attribution. + +## Policy Shape + +The policy uses four internal levels: + +- `SLIM`: CR 4/5 +- `NORMAL`: CR 4/6 +- `ROBUST`: CR 4/7 +- `RESCUE`: CR 4/8 + +Routine telemetry, position, NodeInfo, map reports, range-test packets, and store-and-forward bulk prefer compact CRs. Text and other user-value packets are balanced. Routing/control traffic gets a robust bias. Alerts and detection events get the strongest bias, bounded by legal/duty-cycle constraints. + +Retries only escalate aggressively when the failure looks quiet/link-related. Congested retries prefer more backoff and compact CR, because longer airtime does not fix collisions. + +Relays choose their own CR per hop. A previous hop's CR is treated as an observation, not a command. + +## Safety Rails + +DCR has several clamps before a packet reaches the radio: + +- internal min/max CR +- telemetry/user/alert class bounds +- per-class airtime caps +- duty-cycle pressure bias +- non-urgent CR 4/8 token bucket +- the on/off mode gate + +The token bucket is intentionally local. It prevents a node from spending a quiet channel entirely on CR 4/8 background traffic while still allowing urgent packets to bypass the clamp. + +## Backend Notes + +`RadioInterface` owns the common decision flow so RadioLib radios and the Portduino simulator do not duplicate policy code. + +SX127x/RF95 and SX126x backends apply per-packet CR with normal RadioLib `setCodingRate(cr)`. + +LR11x0 and SX128x static config currently restores their existing long-interleaving behavior where supported. DCR TX calls use normal interleaving and then restore the base static setting after TX. + +## Testing Status + +`test/test_dynamic_coding_rate` covers: + +- default settings and clamps +- compact telemetry under congestion +- idle telemetry avoiding CR 4/8 +- idle text using CR 4/7 +- alert packets using CR 4/8 +- quiet final retry escalation +- congested retry not jumping to CR 4/8 +- CR 4/8 token-bucket clamping +- internal CR bounds staying authoritative through later safety clamps +- off mode +- direct RX neighbor CR attribution + +Hardware interop still needs real-radio validation across SX126x, SX127x/RF95, SX128x, LR11x0, static-CR nodes, and mixed DCR/static relays. diff --git a/src/main.cpp b/src/main.cpp index 979517f0a0b..31fdcfa358c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,6 +5,7 @@ #if !MESHTASTIC_EXCLUDE_INPUTBROKER #include "input/InputBroker.h" #endif +#include "AirtimePolicy.h" #include "MeshRadio.h" #include "MeshService.h" #include "NodeDB.h" @@ -1020,6 +1021,10 @@ void setup() // Start airtime logger thread. airTime = new AirTime(); + // AirtimePolicy owns packet-level radio discipline decisions such as DCR. + // It is kept separate from modules so telemetry, routing, and relays all + // share the same CR budget and congestion view. + airtimePolicy = new AirtimePolicy(); if (!rIf) RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_NO_RADIO); diff --git a/src/mesh/AirtimePolicy.cpp b/src/mesh/AirtimePolicy.cpp new file mode 100644 index 00000000000..c3480f3cb44 --- /dev/null +++ b/src/mesh/AirtimePolicy.cpp @@ -0,0 +1,540 @@ +#include "AirtimePolicy.h" + +#include "NodeDB.h" +#include "configuration.h" +#include "meshUtils.h" + +#include +#include + +AirtimePolicy *airtimePolicy = nullptr; + +uint8_t AirtimePolicy::clampCr(uint8_t cr, uint8_t fallback) +{ + if (cr >= DCR_CR_SLIM && cr <= DCR_CR_RESCUE) + return cr; + + return fallback; +} + +uint8_t AirtimePolicy::crIndex(uint8_t cr) +{ + cr = clampCr(cr); + return cr - DCR_CR_SLIM; +} + +DcrSettings AirtimePolicy::settingsFromConfig(const meshtastic_Config_LoRaConfig &loraConfig) const +{ + DcrSettings settings; + settings.mode = loraConfig.dcr_mode; + if (settings.mode < _meshtastic_Config_LoRaConfig_DynamicCodingRateMode_MIN || + settings.mode > _meshtastic_Config_LoRaConfig_DynamicCodingRateMode_MAX) + settings.mode = meshtastic_Config_LoRaConfig_DynamicCodingRateMode_DCR_OFF; + return settings; +} + +DcrPacketClass AirtimePolicy::classifyPacket(const meshtastic_MeshPacket &packet, meshtastic_PortNum *portnum, bool *known) const +{ + if (known) + *known = false; + if (portnum) + *portnum = meshtastic_PortNum_UNKNOWN_APP; + + if (packet.priority >= meshtastic_MeshPacket_Priority_ALERT) + return DcrPacketClass::Urgent; + if (packet.priority >= meshtastic_MeshPacket_Priority_ACK) + return DcrPacketClass::Control; + + // Repeaters may forward encrypted packets, or packets they intentionally + // did not decode. In that case priority is the only payload-independent + // signal we can trust here. + if (packet.which_payload_variant != meshtastic_MeshPacket_decoded_tag) { + if (packet.priority <= meshtastic_MeshPacket_Priority_BACKGROUND) + return DcrPacketClass::Expendable; + if (packet.priority >= meshtastic_MeshPacket_Priority_HIGH) + return DcrPacketClass::Normal; + return DcrPacketClass::Normal; + } + + if (known) + *known = true; + if (portnum) + *portnum = packet.decoded.portnum; + + switch (packet.decoded.portnum) { + case meshtastic_PortNum_TELEMETRY_APP: + case meshtastic_PortNum_POSITION_APP: + case meshtastic_PortNum_NODEINFO_APP: + case meshtastic_PortNum_NEIGHBORINFO_APP: + case meshtastic_PortNum_MAP_REPORT_APP: + case meshtastic_PortNum_RANGE_TEST_APP: + case meshtastic_PortNum_STORE_FORWARD_APP: + case meshtastic_PortNum_STORE_FORWARD_PLUSPLUS_APP: + return DcrPacketClass::Expendable; + + case meshtastic_PortNum_ROUTING_APP: + return DcrPacketClass::Control; + + case meshtastic_PortNum_ALERT_APP: + case meshtastic_PortNum_DETECTION_SENSOR_APP: + return DcrPacketClass::Urgent; + + case meshtastic_PortNum_TEXT_MESSAGE_APP: + case meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP: + case meshtastic_PortNum_WAYPOINT_APP: + case meshtastic_PortNum_ADMIN_APP: + case meshtastic_PortNum_TRACEROUTE_APP: + return DcrPacketClass::Normal; + + default: + break; + } + + if (packet.want_ack || packet.priority >= meshtastic_MeshPacket_Priority_RELIABLE) + return DcrPacketClass::Control; + + return DcrPacketClass::Normal; +} + +void AirtimePolicy::rememberPacketClass(const meshtastic_MeshPacket &packet, uint32_t nowMsec) +{ + bool known = false; + meshtastic_PortNum portnum = meshtastic_PortNum_UNKNOWN_APP; + DcrPacketClass packetClass = classifyPacket(packet, &portnum, &known); + + PacketClassCache &slot = classCache[nextClassCache++ % PACKET_CLASS_CACHE_SIZE]; + slot.packetPtr = &packet; + slot.from = getFrom(&packet); + slot.id = packet.id; + slot.portnum = portnum; + slot.packetClass = packetClass; + slot.priority = packet.priority; + slot.updatedMsec = nowMsec; + slot.classKnown = known; +} + +const AirtimePolicy::PacketClassCache *AirtimePolicy::findPacketClass(const meshtastic_MeshPacket &packet) const +{ + NodeNum from = getFrom(&packet); + + for (const auto &slot : classCache) { + if (slot.packetPtr == &packet && slot.id == packet.id && slot.from == from) + return &slot; + } + + if (packet.id == 0) + return nullptr; + + for (const auto &slot : classCache) { + if (slot.id == packet.id && slot.from == from) + return &slot; + } + + return nullptr; +} + +DcrDecision AirtimePolicy::choose(const DcrPacketContext &ctx, const ChannelAirtimeStats &channel, const DcrSettings &settings, + uint32_t (*airtimeForCr)(uint32_t packetLen, uint8_t cr, void *context), void *airtimeContext) +{ + DcrDecision decision; + decision.cr = clampCr(ctx.baseCr); + decision.changed = false; + decision.predictedAirtimeMs = ctx.predictedAirtimeMs; + + if (settings.mode == meshtastic_Config_LoRaConfig_DynamicCodingRateMode_DCR_OFF) + return decision; + + DcrPacketClass packetClass = ctx.packetClass; + bool classKnown = ctx.classKnown; + + if (ctx.packet) { + if (const PacketClassCache *cached = findPacketClass(*ctx.packet)) { + // Prefer the pre-encryption classification for local-origin packets. + // It can know the real portnum even after Router::send() encrypts. + packetClass = cached->packetClass; + classKnown = cached->classKnown; + } + } + + decision.packetClass = packetClass; + + // The score is intentionally small and saturates into four CR levels below. + // Large, clever-looking weights are dangerous here: they make one signal + // dominate and can turn "more FEC" into channel poisoning. + int8_t score = 0; + switch (packetClass) { + case DcrPacketClass::Expendable: + score -= 1; + decision.reasonFlags |= DCR_REASON_PERIODIC; + break; + case DcrPacketClass::Normal: + decision.reasonFlags |= DCR_REASON_USER; + break; + case DcrPacketClass::Control: + score += 1; + decision.reasonFlags |= DCR_REASON_CONTROL; + break; + case DcrPacketClass::Urgent: + score += 2; + decision.reasonFlags |= DCR_REASON_URGENT; + break; + } + + bool idle = channel.channelUtilizationPercent <= 2.0f && channel.queueDepth <= 1; + bool congested = channel.channelUtilizationPercent >= 25.0f || channel.queueDepth >= 6; + bool busy = !congested && (channel.channelUtilizationPercent >= 10.0f || channel.queueDepth >= 3); + + // FEC helps weak links, not collisions. Congestion therefore pushes CR down + // even for some important packets; retries can still add robustness later + // only when the loss looked quiet/link-related. + if (idle) { + score += 1; + decision.reasonFlags |= DCR_REASON_IDLE; + } else if (congested) { + score -= 2; + decision.reasonFlags |= DCR_REASON_CONGESTED | DCR_REASON_COLLISION_PRESSURE; + } else if (busy) { + score -= 1; + decision.reasonFlags |= DCR_REASON_BUSY; + } + + if (ctx.relay) { + score -= 1; + decision.reasonFlags |= DCR_REASON_RELAY; + } + if (ctx.lateRelay) { + score += 1; + decision.reasonFlags |= DCR_REASON_LATE_RELAY; + } + if (ctx.lastHop) { + score += 1; + decision.reasonFlags |= DCR_REASON_LAST_HOP; + } + + // RX SNR is only a hint for the next hop, especially on asymmetric links. + // Keep it as a light bias rather than a hard decision. + if (ctx.rxSnr < -8.0f && (ctx.rxSnr != 0.0f || ctx.rxRssi != 0)) { + score += 1; + decision.reasonFlags |= DCR_REASON_WEAK_LINK; + } else if (ctx.rxSnr > 5.0f) { + score -= 1; + decision.reasonFlags |= DCR_REASON_STRONG_LINK; + } + + if (ctx.retry.attempt > 0) { + decision.reasonFlags |= DCR_REASON_RETRY; + // Quiet loss means "more redundancy may help"; busy/congested means + // "longer airtime may hurt everyone". Do not grant both at once. + if (ctx.retry.quietLoss && !busy && !congested) { + score += std::min(ctx.retry.attempt, 2); + decision.reasonFlags |= DCR_REASON_QUIET_LOSS; + } else { + decision.reasonFlags |= DCR_REASON_COLLISION_PRESSURE; + } + if (ctx.retry.finalRetry && ctx.retry.quietLoss && !busy && !congested) { + score += 1; + decision.reasonFlags |= DCR_REASON_FINAL_RETRY; + } + } + + if (channel.dutyCyclePercent < 100.0f) { + float politeDuty = channel.dutyCyclePercent * 0.5f; + if (channel.txUtilizationPercent >= politeDuty) { + // This is not legal enforcement; Router/AirTime already enforce TX. + // DCR just avoids selecting expensive CRs while local TX airtime is + // approaching the region's allowance. + score -= 2; + decision.reasonFlags |= DCR_REASON_DUTY_CYCLE; + } + } + + uint8_t wantedCr = DCR_CR_NORMAL; + if (score <= -2) + wantedCr = DCR_CR_SLIM; + else if (score == -1) + wantedCr = DCR_CR_SLIM; + else if (score == 0) + wantedCr = DCR_CR_NORMAL; + else if (score == 1) + wantedCr = DCR_CR_ROBUST; + else + wantedCr = DCR_CR_RESCUE; + + // Class clamps apply before global min/max so user configuration remains + // the final authority. Example: telemetry normally caps at CR 4/6, but an + // operator can still force min=max=8 for a controlled experiment. + if (packetClass == DcrPacketClass::Expendable) + wantedCr = std::min(wantedCr, settings.telemetryMaxCr); + if (packetClass == DcrPacketClass::Normal) + wantedCr = std::max(wantedCr, settings.userMinCr); + if (packetClass == DcrPacketClass::Urgent) + wantedCr = std::max(wantedCr, settings.alertMinCr); + + // Header-only relay context should not jump to rescue CR without knowing + // whether the payload is a human alert or a stale background replay. + if (!classKnown && ctx.relay && wantedCr > DCR_CR_ROBUST) + wantedCr = DCR_CR_ROBUST; + + wantedCr = std::max(settings.minCr, std::min(settings.maxCr, wantedCr)); + + bool urgent = packetClass == DcrPacketClass::Urgent; + uint32_t predictedAirtime = ctx.predictedAirtimeMs; + if (airtimeForCr) + predictedAirtime = airtimeForCr(ctx.packetLen, wantedCr, airtimeContext); + + uint32_t airtimeCap = 0; + if (packetClass == DcrPacketClass::Expendable) + airtimeCap = 750; + else if (packetClass == DcrPacketClass::Normal) + airtimeCap = idle ? 2200 : 1400; + else if (packetClass == DcrPacketClass::Control) + airtimeCap = 1600; + + while (airtimeCap && wantedCr > settings.minCr && predictedAirtime > airtimeCap) { + wantedCr--; + decision.reasonFlags |= DCR_REASON_AIRTIME_CAP; + counters.forcedCompactAirtimeCap++; + if (airtimeForCr) + predictedAirtime = airtimeForCr(ctx.packetLen, wantedCr, airtimeContext); + } + + if (wantedCr == DCR_CR_RESCUE && !robustTokenAllows(predictedAirtime, urgent, settings.robustAirtimePct, ctx.nowMsec)) { + // CR 4/8 is useful but socially expensive. Non-urgent packets must + // spend from a local rolling budget so idle telemetry cannot become + // slow background noise. + uint8_t beforeClamp = wantedCr; + wantedCr = std::max(settings.minCr, std::min(settings.maxCr, DCR_CR_ROBUST)); + decision.reasonFlags |= DCR_REASON_TOKEN_BUCKET; + if (wantedCr < beforeClamp) + counters.forcedCompactTokenBucket++; + if (airtimeForCr) + predictedAirtime = airtimeForCr(ctx.packetLen, wantedCr, airtimeContext); + } + + if ((busy || congested) && wantedCr > DCR_CR_NORMAL && packetClass == DcrPacketClass::Expendable) { + // Background traffic should be first to go compact when the air gets + // crowded. User-specified minCr can still override this for lab tests. + uint8_t beforeClamp = wantedCr; + wantedCr = std::max(settings.minCr, DCR_CR_NORMAL); + decision.reasonFlags |= congested ? DCR_REASON_CONGESTED : DCR_REASON_BUSY; + if (wantedCr < beforeClamp) { + counters.forcedCompactCongestion++; + if (airtimeForCr) + predictedAirtime = airtimeForCr(ctx.packetLen, wantedCr, airtimeContext); + } + } + + wantedCr = std::max(settings.minCr, std::min(settings.maxCr, wantedCr)); + decision.cr = wantedCr; + decision.changed = wantedCr != ctx.baseCr; + decision.score = score; + decision.predictedAirtimeMs = predictedAirtime; + return decision; +} + +void AirtimePolicy::rotateRobustWindow(uint32_t nowMsec) +{ + if (robustWindowStartMsec == 0) { + robustWindowStartMsec = nowMsec ? nowMsec : 1; + return; + } + if (nowMsec - robustWindowStartMsec >= ROBUST_WINDOW_MSEC) { + robustWindowStartMsec = nowMsec ? nowMsec : 1; + robustTotalAirtimeMs = 0; + robustRescueAirtimeMs = 0; + } +} + +bool AirtimePolicy::robustTokenAllows(uint32_t predictedAirtimeMs, bool urgent, uint8_t robustAirtimePct, uint32_t nowMsec) +{ + if (urgent) + return true; + + rotateRobustWindow(nowMsec); + if (robustTotalAirtimeMs == 0) + return true; + + uint32_t total = robustTotalAirtimeMs + predictedAirtimeMs; + uint32_t rescue = robustRescueAirtimeMs + predictedAirtimeMs; + return rescue * 100 <= total * robustAirtimePct; +} + +void AirtimePolicy::observeRx(const DcrRxObservation &obs, bool trackNeighborCr) +{ + if (obs.rxCr < DCR_CR_SLIM || obs.rxCr > DCR_CR_RESCUE) { + counters.rxCrUnknown++; + return; + } + + counters.rxCr[crIndex(obs.rxCr)]++; + if (!trackNeighborCr) + return; + + NodeNum node = 0; + if (obs.hopStart != 0 && obs.hopStart == obs.hopLimit) { + // Direct packet: the RF header CR belongs to the original sender. + node = obs.from; + } else if (obs.relayNode != NO_RELAY_NODE) { + // Relayed packet: the RF header CR belongs to the last transmitter. + // Only attribute it when the one-byte relay id maps unambiguously. + node = resolveRelayNode(obs.relayNode); + } + + if (!node) + return; + + NeighborCrStats *stats = getOrCreateNeighborStats(node); + if (!stats) + return; + + uint8_t idx = crIndex(obs.rxCr); + if (stats->rxCrHist[idx] < UINT8_MAX) + stats->rxCrHist[idx]++; + stats->lastRxCr = obs.rxCr; + stats->rxCrEwma = stats->rxCrEwma == 0.0f ? obs.rxCr : stats->rxCrEwma * 0.75f + obs.rxCr * 0.25f; + stats->lastSnr = obs.snr; + stats->lastRssi = obs.rssi; + stats->lastSeenMsec = obs.nowMsec; +} + +void AirtimePolicy::observeTxStart(const meshtastic_MeshPacket &packet, uint8_t cr, uint32_t airtimeMs, bool urgent, + uint32_t nowMsec) +{ + cr = clampCr(cr); + counters.txCr[crIndex(cr)]++; + counters.txAirtimeMsByCr[crIndex(cr)] += airtimeMs; + + rotateRobustWindow(nowMsec); + robustTotalAirtimeMs += airtimeMs; + if (cr == DCR_CR_RESCUE && !urgent) + robustRescueAirtimeMs += airtimeMs; + + // Keep the chosen CR with the packet identity for later ACK/NAK/timeout + // accounting. The ACK path does not know which CR the original TX used. + TxCache &slot = txCache[nextTxCache++ % TX_CACHE_SIZE]; + slot.from = getFrom(&packet); + slot.to = packet.to; + slot.id = packet.id; + slot.cr = cr; +} + +void AirtimePolicy::observeTxResult(const DcrTxResult &result) +{ + uint8_t cr = result.cr ? result.cr : getLastTxCr(result.from, result.id); + if (cr < DCR_CR_SLIM || cr > DCR_CR_RESCUE) + return; + + if (result.success) + counters.ackSuccessByCr[crIndex(cr)]++; + else + counters.ackFailByCr[crIndex(cr)]++; + + if (isBroadcast(result.to)) + return; + + NeighborCrStats *stats = getOrCreateNeighborStats(result.to); + if (!stats) + return; + + uint8_t idx = crIndex(cr); + stats->lastTxCr = cr; + stats->txAttemptsByCr[idx]++; + if (result.success) + stats->txSuccessByCr[idx]++; + else + stats->txFailByCr[idx]++; +} + +void AirtimePolicy::noteRetransmission(const meshtastic_MeshPacket &packet, uint8_t attempt, bool finalRetry, bool quietLoss) +{ + // Store retry intent as metadata for the next radio-layer decision. The + // router schedules retries; DCR only consumes the context just before TX. + RetryCache &slot = retryCache[nextRetryCache++ % RETRY_CACHE_SIZE]; + slot.from = getFrom(&packet); + slot.id = packet.id; + slot.attempt = attempt; + slot.finalRetry = finalRetry; + slot.quietLoss = quietLoss; + counters.retryEscalations++; +} + +DcrRetryContext AirtimePolicy::getRetryContext(const meshtastic_MeshPacket &packet) const +{ + NodeNum from = getFrom(&packet); + for (const auto &slot : retryCache) { + if (slot.from == from && slot.id == packet.id) + return {slot.attempt, slot.finalRetry, slot.quietLoss}; + } + + return {}; +} + +uint8_t AirtimePolicy::getLastTxCr(NodeNum from, PacketId id) const +{ + for (const auto &slot : txCache) { + if (slot.from == from && slot.id == id) + return slot.cr; + } + + return 0; +} + +const NeighborCrStats *AirtimePolicy::getNeighborStats(NodeNum nodeNum) const +{ + if (!nodeNum) + return nullptr; + + for (const auto &stats : neighborStats) { + if (stats.nodeNum == nodeNum) + return &stats; + } + + return nullptr; +} + +NeighborCrStats *AirtimePolicy::getOrCreateNeighborStats(NodeNum nodeNum) +{ + if (!nodeNum) + return nullptr; + + for (auto &stats : neighborStats) { + if (stats.nodeNum == nodeNum) + return &stats; + } + + for (auto &stats : neighborStats) { + if (stats.nodeNum == 0) { + stats = {}; + stats.nodeNum = nodeNum; + return &stats; + } + } + + NeighborCrStats &slot = neighborStats[nodeNum % NEIGHBOR_CR_SLOTS]; + slot = {}; + slot.nodeNum = nodeNum; + return &slot; +} + +NodeNum AirtimePolicy::resolveRelayNode(uint8_t relayNode) const +{ + if (!nodeDB || relayNode == NO_RELAY_NODE) + return 0; + + // The packet header stores only the last byte of the relay node. Treat + // collisions as ambiguous instead of attributing another node's physical + // CR to the wrong neighbor. + NodeNum found = 0; + for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { + meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i); + if (!node || node->num == 0) + continue; + if (nodeDB->getLastByteOfNodeNum(node->num) == relayNode) { + if (found != 0) + return 0; + found = node->num; + } + } + + return found; +} diff --git a/src/mesh/AirtimePolicy.h b/src/mesh/AirtimePolicy.h new file mode 100644 index 00000000000..50587b74af9 --- /dev/null +++ b/src/mesh/AirtimePolicy.h @@ -0,0 +1,248 @@ +#pragma once + +#include "MeshTypes.h" +#include "mesh/generated/meshtastic/config.pb.h" +#include "mesh/generated/meshtastic/portnums.pb.h" + +#include + +constexpr uint8_t DCR_CR_SLIM = 5; +constexpr uint8_t DCR_CR_NORMAL = 6; +constexpr uint8_t DCR_CR_ROBUST = 7; +constexpr uint8_t DCR_CR_RESCUE = 8; + +// Coarse packet classes keep the policy stable when the payload is already +// encrypted or a repeater is running in a mode that skips decoding. Portnums +// refine the class when we still have decoded local context. +enum class DcrPacketClass : uint8_t { Expendable = 0, Normal = 1, Control = 2, Urgent = 3 }; + +enum DcrReasonFlags : uint32_t { + DCR_REASON_NONE = 0, + DCR_REASON_IDLE = 1 << 0, + DCR_REASON_BUSY = 1 << 1, + DCR_REASON_CONGESTED = 1 << 2, + DCR_REASON_PERIODIC = 1 << 3, + DCR_REASON_USER = 1 << 4, + DCR_REASON_CONTROL = 1 << 5, + DCR_REASON_URGENT = 1 << 6, + DCR_REASON_RETRY = 1 << 7, + DCR_REASON_FINAL_RETRY = 1 << 8, + DCR_REASON_QUIET_LOSS = 1 << 9, + DCR_REASON_COLLISION_PRESSURE = 1 << 10, + DCR_REASON_RELAY = 1 << 11, + DCR_REASON_LATE_RELAY = 1 << 12, + DCR_REASON_LAST_HOP = 1 << 13, + DCR_REASON_WEAK_LINK = 1 << 14, + DCR_REASON_STRONG_LINK = 1 << 15, + DCR_REASON_DUTY_CYCLE = 1 << 16, + DCR_REASON_TOKEN_BUCKET = 1 << 17, + DCR_REASON_AIRTIME_CAP = 1 << 18, +}; + +struct DcrSettings { + meshtastic_Config_LoRaConfig_DynamicCodingRateMode mode = + meshtastic_Config_LoRaConfig_DynamicCodingRateMode_DCR_OFF; + // These are LoRa CR denominators: 5 means CR 4/5, 8 means CR 4/8. + uint8_t minCr = DCR_CR_SLIM; + uint8_t maxCr = DCR_CR_RESCUE; + // Class clamps deliberately make background traffic spend less of the + // shared robust-airtime budget than user/control/urgent traffic. + uint8_t telemetryMaxCr = DCR_CR_NORMAL; + // Let normal user traffic become CR 4/5 under pressure by default. Idle, + // weak-link, and retry scoring can still raise it back to robust CRs. + uint8_t userMinCr = DCR_CR_SLIM; + uint8_t alertMinCr = DCR_CR_ROBUST; + uint8_t robustAirtimePct = 10; + bool trackNeighborCr = true; +}; + +struct ChannelAirtimeStats { + float channelUtilizationPercent = 0.0f; + float txUtilizationPercent = 0.0f; + float dutyCyclePercent = 100.0f; + uint8_t queueDepth = 0; +}; + +struct DcrRetryContext { + uint8_t attempt = 0; + bool finalRetry = false; + // True when the routing layer thinks the last loss was quiet/link-related. + // The policy still rechecks current channel pressure before adding FEC. + bool quietLoss = false; +}; + +struct DcrPacketContext { + const meshtastic_MeshPacket *packet = nullptr; + meshtastic_PortNum portnum = meshtastic_PortNum_UNKNOWN_APP; + DcrPacketClass packetClass = DcrPacketClass::Normal; + meshtastic_MeshPacket_Priority priority = meshtastic_MeshPacket_Priority_UNSET; + uint8_t baseCr = DCR_CR_SLIM; + uint32_t packetLen = 0; + uint32_t predictedAirtimeMs = 0; + DcrRetryContext retry; + bool classKnown = false; + bool localOrigin = false; + bool relay = false; + bool lateRelay = false; + bool lastHop = false; + bool direct = false; + float rxSnr = 0.0f; + int32_t rxRssi = 0; + uint32_t nowMsec = 0; +}; + +struct DcrDecision { + uint8_t cr = DCR_CR_SLIM; + DcrPacketClass packetClass = DcrPacketClass::Normal; + uint32_t reasonFlags = DCR_REASON_NONE; + int8_t score = 0; + uint32_t predictedAirtimeMs = 0; + bool changed = false; +}; + +struct DcrRxObservation { + NodeNum from = 0; + uint8_t relayNode = 0; + uint8_t hopStart = 0; + uint8_t hopLimit = 0; + uint8_t rxCr = 0; + uint32_t airtimeMs = 0; + float snr = 0.0f; + int32_t rssi = 0; + uint32_t nowMsec = 0; +}; + +struct DcrTxResult { + NodeNum to = 0; + NodeNum from = 0; + PacketId id = 0; + uint8_t cr = 0; + bool success = false; +}; + +struct NeighborCrStats { + NodeNum nodeNum = 0; + // RX CR is the physical CR of the last transmitter we heard on this hop, + // not necessarily the original MeshPacket sender. + uint8_t lastRxCr = 0; + uint8_t rxCrHist[4] = {}; + float rxCrEwma = 0.0f; + float lastSnr = 0.0f; + int32_t lastRssi = 0; + uint32_t lastSeenMsec = 0; + uint8_t lastTxCr = 0; + uint16_t txAttemptsByCr[4] = {}; + uint16_t txSuccessByCr[4] = {}; + uint16_t txFailByCr[4] = {}; + uint8_t capabilityFlags = 0; +}; + +struct DcrCounters { + uint32_t txCr[4] = {}; + uint32_t rxCr[4] = {}; + uint32_t rxCrUnknown = 0; + uint32_t txAirtimeMsByCr[4] = {}; + uint32_t ackSuccessByCr[4] = {}; + uint32_t ackFailByCr[4] = {}; + uint32_t retryEscalations = 0; + uint32_t forcedCompactCongestion = 0; + uint32_t forcedCompactDutyCycle = 0; + uint32_t forcedCompactTokenBucket = 0; + uint32_t forcedCompactAirtimeCap = 0; + uint32_t relayLateRobust = 0; +}; + +/** + * Central policy brain for choices that trade airtime against reliability. + * + * DynamicCodingRate is intentionally implemented here instead of in telemetry, + * routing, or individual radio backends: those subsystems all want airtime, and + * one shared policy is the only way to make CR8 budget, congestion pressure, + * retry context, and relay context agree with each other. + */ +class AirtimePolicy +{ + public: + DcrSettings settingsFromConfig(const meshtastic_Config_LoRaConfig &loraConfig) const; + + void rememberPacketClass(const meshtastic_MeshPacket &packet, uint32_t nowMsec); + DcrPacketClass classifyPacket(const meshtastic_MeshPacket &packet, meshtastic_PortNum *portnum, bool *known) const; + + DcrDecision choose(const DcrPacketContext &packet, const ChannelAirtimeStats &channel, const DcrSettings &settings, + uint32_t (*airtimeForCr)(uint32_t packetLen, uint8_t cr, void *context), void *airtimeContext); + + void observeRx(const DcrRxObservation &obs, bool trackNeighborCr); + void observeTxStart(const meshtastic_MeshPacket &packet, uint8_t cr, uint32_t airtimeMs, bool urgent, uint32_t nowMsec); + void observeTxResult(const DcrTxResult &result); + + void noteRetransmission(const meshtastic_MeshPacket &packet, uint8_t attempt, bool finalRetry, bool quietLoss); + DcrRetryContext getRetryContext(const meshtastic_MeshPacket &packet) const; + uint8_t getLastTxCr(NodeNum from, PacketId id) const; + + const DcrCounters &getCounters() const { return counters; } + const NeighborCrStats *getNeighborStats(NodeNum nodeNum) const; + + static uint8_t clampCr(uint8_t cr, uint8_t fallback = DCR_CR_SLIM); + static uint8_t crIndex(uint8_t cr); + + private: + struct PacketClassCache { + // Local-origin decoded packets are classified before encryption, then + // looked up again immediately before TX. This avoids adding policy-only + // fields to MeshPacket or inspecting encrypted payloads later. + const meshtastic_MeshPacket *packetPtr = nullptr; + NodeNum from = 0; + PacketId id = 0; + meshtastic_PortNum portnum = meshtastic_PortNum_UNKNOWN_APP; + DcrPacketClass packetClass = DcrPacketClass::Normal; + meshtastic_MeshPacket_Priority priority = meshtastic_MeshPacket_Priority_UNSET; + uint32_t updatedMsec = 0; + bool classKnown = false; + }; + + struct RetryCache { + // Retransmission state is keyed by packet identity because DCR chooses + // CR at the radio layer, later than the router's retry scheduling. + NodeNum from = 0; + PacketId id = 0; + uint8_t attempt = 0; + bool finalRetry = false; + bool quietLoss = false; + }; + + struct TxCache { + // ACK/NAK/timeout accounting happens after TX, so remember the actual + // CR selected for the packet rather than assuming the static/base CR. + NodeNum from = 0; + NodeNum to = 0; + PacketId id = 0; + uint8_t cr = 0; + }; + + static constexpr size_t PACKET_CLASS_CACHE_SIZE = 24; + static constexpr size_t RETRY_CACHE_SIZE = 16; + static constexpr size_t TX_CACHE_SIZE = 16; + static constexpr size_t NEIGHBOR_CR_SLOTS = 32; + static constexpr uint32_t ROBUST_WINDOW_MSEC = 5 * 60 * 1000; + + PacketClassCache classCache[PACKET_CLASS_CACHE_SIZE] = {}; + RetryCache retryCache[RETRY_CACHE_SIZE] = {}; + TxCache txCache[TX_CACHE_SIZE] = {}; + NeighborCrStats neighborStats[NEIGHBOR_CR_SLOTS] = {}; + DcrCounters counters = {}; + + uint32_t robustWindowStartMsec = 0; + uint32_t robustTotalAirtimeMs = 0; + uint32_t robustRescueAirtimeMs = 0; + size_t nextClassCache = 0; + size_t nextRetryCache = 0; + size_t nextTxCache = 0; + + const PacketClassCache *findPacketClass(const meshtastic_MeshPacket &packet) const; + NeighborCrStats *getOrCreateNeighborStats(NodeNum nodeNum); + NodeNum resolveRelayNode(uint8_t relayNode) const; + void rotateRobustWindow(uint32_t nowMsec); + bool robustTokenAllows(uint32_t predictedAirtimeMs, bool urgent, uint8_t robustAirtimePct, uint32_t nowMsec); +}; + +extern AirtimePolicy *airtimePolicy; diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index 1d1616ed673..665d4443bf4 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -215,6 +215,34 @@ template bool LR11x0Interface::reconfigure() return true; } +template bool LR11x0Interface::setActiveCodingRate(uint8_t codingRate) +{ + if (codingRate < 5 || codingRate > 8) + return false; + + // Keep the preset's long-interleaving behavior while changing only the CR. + int err = lora.setCodingRate(codingRate, codingRate != 7); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR11x0 set DCR coding rate %s%d", radioLibErr, err); + return false; + } + + activeCr = codingRate; + return true; +} + +template bool LR11x0Interface::restoreBaseCodingRate() +{ + int err = lora.setCodingRate(cr, cr != 7); // use long interleaving except if CR is 4/7 which doesn't support it + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR11x0 restore coding rate %s%d", radioLibErr, err); + return false; + } + + activeCr = cr; + return true; +} + template void LR11x0Interface::disableInterrupt() { lora.clearIrqAction(); diff --git a/src/mesh/LR11x0Interface.h b/src/mesh/LR11x0Interface.h index 1a6b925206b..83945182892 100644 --- a/src/mesh/LR11x0Interface.h +++ b/src/mesh/LR11x0Interface.h @@ -71,5 +71,11 @@ template class LR11x0Interface : public RadioLibInterface virtual void setStandby() override; uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } + uint32_t getPacketTimeForCodingRate(uint32_t pl, uint8_t codingRate) override + { + return computePacketTimeForCodingRate(lora, pl, codingRate); + } + bool setActiveCodingRate(uint8_t codingRate) override; + bool restoreBaseCodingRate() override; }; -#endif \ No newline at end of file +#endif diff --git a/src/mesh/LR20x0Interface.cpp b/src/mesh/LR20x0Interface.cpp index 699663cf032..f813e2e09cd 100644 --- a/src/mesh/LR20x0Interface.cpp +++ b/src/mesh/LR20x0Interface.cpp @@ -243,6 +243,34 @@ template bool LR20x0Interface::reconfigure() return true; } +template bool LR20x0Interface::setActiveCodingRate(uint8_t codingRate) +{ + if (codingRate < 5 || codingRate > 8) + return false; + + // Keep the preset's long-interleaving behavior while changing only the CR. + int err = lora.setCodingRate(codingRate, codingRate != 7); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR20x0 set DCR coding rate %s%d", radioLibErr, err); + return false; + } + + activeCr = codingRate; + return true; +} + +template bool LR20x0Interface::restoreBaseCodingRate() +{ + int err = lora.setCodingRate(cr, cr != 7); // use long interleaving except if CR is 4/7 which doesn't support it + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("LR20x0 restore coding rate %s%d", radioLibErr, err); + return false; + } + + activeCr = cr; + return true; +} + template void LR20x0Interface::disableInterrupt() { lora.clearIrqAction(); diff --git a/src/mesh/LR20x0Interface.h b/src/mesh/LR20x0Interface.h index 6176cd0b8e8..0eec6d60ec0 100644 --- a/src/mesh/LR20x0Interface.h +++ b/src/mesh/LR20x0Interface.h @@ -71,5 +71,11 @@ template class LR20x0Interface : public RadioLibInterface virtual void setStandby() override; uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } + uint32_t getPacketTimeForCodingRate(uint32_t pl, uint8_t codingRate) override + { + return computePacketTimeForCodingRate(lora, pl, codingRate); + } + bool setActiveCodingRate(uint8_t codingRate) override; + bool restoreBaseCodingRate() override; }; #endif diff --git a/src/mesh/MeshPacketQueue.h b/src/mesh/MeshPacketQueue.h index 3d3902c1ea6..8304f1f6107 100644 --- a/src/mesh/MeshPacketQueue.h +++ b/src/mesh/MeshPacketQueue.h @@ -33,6 +33,9 @@ class MeshPacketQueue /** return total size of the Queue */ size_t getMaxLen() { return maxLen; } + /** return amount of packets currently waiting in Queue */ + size_t size() const { return queue.size(); } + meshtastic_MeshPacket *dequeue(); meshtastic_MeshPacket *getFront(); @@ -46,4 +49,4 @@ class MeshPacketQueue /* Attempt to find a packet from this queue. Return true if it was found. */ bool find(const NodeNum from, const PacketId id); -}; \ No newline at end of file +}; diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index e8613d45729..9b185785601 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -1,5 +1,9 @@ #include "NextHopRouter.h" +#include "AirtimePolicy.h" +#include "MeshRadio.h" #include "MeshTypes.h" +#include "airtime.h" +#include "configuration.h" #include "meshUtils.h" #if !MESHTASTIC_EXCLUDE_TRACEROUTE #include "modules/TraceRouteModule.h" @@ -9,12 +13,48 @@ #endif #include "NodeDB.h" +namespace +{ +bool quietLossChannelUtilizationLimit(float *limit) +{ + if (!myRegion || config.lora.override_duty_cycle) + return false; + + float regionDutyCycle = myRegion->dutyCycle; + if (regionDutyCycle <= 0.0f || regionDutyCycle >= 100.0f) + return false; + + // Quiet-loss retry escalation is a reliability bias, not duty-cycle enforcement. + // Use an actual regional duty-cycle limit when there is one; do not invent + // a local utilization threshold for unrestricted or explicitly overridden regions. + *limit = regionDutyCycle; + return true; +} + +bool isQuietRetransmissionLoss() +{ + if (!airTime) + return true; + + float utilizationLimit = 0.0f; + if (!quietLossChannelUtilizationLimit(&utilizationLimit)) { + // No regulatory duty-cycle limit means there is no region-derived + // utilization threshold here. Let AirtimePolicy's current busy/congested + // view decide whether this retry may actually add FEC. + return true; + } + + return airTime->channelUtilizationPercent() < utilizationLimit; +} +} // namespace + NextHopRouter::NextHopRouter() {} PendingPacket::PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions) { packet = p; - this->numRetransmissions = numRetransmissions - 1; // We subtract one, because we assume the user just did the first send + initialNumRetransmissions = numRetransmissions > 0 ? numRetransmissions - 1 : 0; + this->numRetransmissions = initialNumRetransmissions; // We assume the user just did the first send } /** @@ -218,10 +258,10 @@ PendingPacket *NextHopRouter::findPendingPacket(GlobalPacketId key) /** * Stop any retransmissions we are doing of the specified node/packet ID pair */ -bool NextHopRouter::stopRetransmission(NodeNum from, PacketId id) +bool NextHopRouter::stopRetransmission(NodeNum from, PacketId id, bool success) { auto key = GlobalPacketId(from, id); - return stopRetransmission(key); + return stopRetransmission(key, success); } bool NextHopRouter::roleAllowsCancelingFromTxQueue(const meshtastic_MeshPacket *p) @@ -233,11 +273,19 @@ bool NextHopRouter::roleAllowsCancelingFromTxQueue(const meshtastic_MeshPacket * return roleAllowsCancelingDupe(p); // same logic as FloodingRouter::roleAllowsCancelingDupe } -bool NextHopRouter::stopRetransmission(GlobalPacketId key) +bool NextHopRouter::stopRetransmission(GlobalPacketId key, bool success) { auto old = findPendingPacket(key); if (old) { auto p = old->packet; + if (success && airtimePolicy) { + airtimePolicy->observeTxResult({.to = p->to, + .from = getFrom(p), + .id = p->id, + .cr = airtimePolicy->getLastTxCr(getFrom(p), p->id), + .success = true}); + } + /* Only when we already transmitted a packet via LoRa, we will cancel the packet in the Tx queue to avoid canceling a transmission if it was ACKed super fast via MQTT */ if (old->numRetransmissions < NUM_RELIABLE_RETX - 1) { @@ -270,7 +318,7 @@ PendingPacket *NextHopRouter::startRetransmission(meshtastic_MeshPacket *p, uint auto id = GlobalPacketId(p); auto rec = PendingPacket(p, numReTx); - stopRetransmission(getFrom(p), p->id); + stopRetransmission(getFrom(p), p->id, false); setNextTx(&rec); pending[id] = rec; @@ -302,13 +350,32 @@ int32_t NextHopRouter::doRetransmissions() p.packet->id); sendAckNak(meshtastic_Routing_Error_MAX_RETRANSMIT, getFrom(p.packet), p.packet->id, p.packet->channel); } + if (airtimePolicy) { + airtimePolicy->observeTxResult({.to = p.packet->to, + .from = getFrom(p.packet), + .id = p.packet->id, + .cr = airtimePolicy->getLastTxCr(getFrom(p.packet), p.packet->id), + .success = false}); + } // Note: we don't stop retransmission here, instead the Nak packet gets processed in sniffReceived - stopRetransmission(it->first); + stopRetransmission(it->first, false); stillValid = false; // just deleted it } else { LOG_DEBUG("Sending retransmission fr=0x%x,to=0x%x,id=0x%x, tries left=%d", p.packet->from, p.packet->to, p.packet->id, p.numRetransmissions); + if (airtimePolicy) { + uint8_t attempt = p.initialNumRetransmissions >= p.numRetransmissions + ? p.initialNumRetransmissions - p.numRetransmissions + 1 + : 1; + // Retry metadata is consumed later, immediately before the + // retransmission hits the radio. That keeps CR selection + // based on fresh queue/channel pressure instead of the + // conditions from when the retry timer was scheduled. + airtimePolicy->noteRetransmission(*p.packet, attempt, p.numRetransmissions == 1, + isQuietRetransmissionLoss()); + } + if (!isBroadcast(p.packet->to)) { if (p.numRetransmissions == 1) { // Last retransmission, reset next_hop (fallback to FloodingRouter) diff --git a/src/mesh/NextHopRouter.h b/src/mesh/NextHopRouter.h index 42ef13cd9a7..903110c16e9 100644 --- a/src/mesh/NextHopRouter.h +++ b/src/mesh/NextHopRouter.h @@ -39,6 +39,9 @@ struct PendingPacket { /** Starts at NUM_RETRANSMISSIONS -1 and counts down. Once zero it will be removed from the list */ uint8_t numRetransmissions = 0; + /** The starting countdown value, used to derive the retransmission attempt without role assumptions */ + uint8_t initialNumRetransmissions = 0; + PendingPacket() {} explicit PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions); }; @@ -130,8 +133,8 @@ class NextHopRouter : public FloodingRouter * * @return true if we found and removed a transmission with this ID */ - bool stopRetransmission(NodeNum from, PacketId id); - bool stopRetransmission(GlobalPacketId p); + bool stopRetransmission(NodeNum from, PacketId id, bool success = true); + bool stopRetransmission(GlobalPacketId p, bool success = true); /** * Do any retransmissions that are scheduled (FIXME - for the time being called from loop) @@ -152,4 +155,4 @@ class NextHopRouter : public FloodingRouter /** Check if we should be rebroadcasting this packet if so, do so. * @return true if we did rebroadcast */ bool perhapsRebroadcast(const meshtastic_MeshPacket *p) override; -}; \ No newline at end of file +}; diff --git a/src/mesh/RF95Interface.cpp b/src/mesh/RF95Interface.cpp index 43149ef8b40..2de490946ab 100644 --- a/src/mesh/RF95Interface.cpp +++ b/src/mesh/RF95Interface.cpp @@ -255,6 +255,24 @@ bool RF95Interface::reconfigure() return true; } +bool RF95Interface::setActiveCodingRate(uint8_t codingRate) +{ + if (!lora || codingRate < 5 || codingRate > 8) + return false; + + if (codingRate == activeCr) + return true; + + int err = lora->setCodingRate(codingRate); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("RF95 set DCR coding rate %s%d", radioLibErr, err); + return false; + } + + activeCr = codingRate; + return true; +} + /** * Add SNR data to received messages */ @@ -341,4 +359,4 @@ bool RF95Interface::sleep() return true; } -#endif \ No newline at end of file +#endif diff --git a/src/mesh/RF95Interface.h b/src/mesh/RF95Interface.h index ffd8ae0082b..d8cb9942947 100644 --- a/src/mesh/RF95Interface.h +++ b/src/mesh/RF95Interface.h @@ -66,6 +66,11 @@ class RF95Interface : public RadioLibInterface virtual void configHardwareForSend() override; uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(*lora, pl, received); } + uint32_t getPacketTimeForCodingRate(uint32_t pl, uint8_t codingRate) override + { + return computePacketTimeForCodingRate(*lora, pl, codingRate); + } + bool setActiveCodingRate(uint8_t codingRate) override; private: /** Some boards require GPIO control of tx vs rx paths */ diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index d4eeed08970..90781ddb354 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -1,4 +1,5 @@ #include "RadioInterface.h" +#include "AirtimePolicy.h" #include "Channels.h" #include "DisplayFormatters.h" #include "LLCC68Interface.h" @@ -19,9 +20,11 @@ #include "main.h" #include "meshUtils.h" // for pow_of_2 #include "sleep.h" +#include #include #include #include +#include #include #ifdef ARCH_PORTDUINO @@ -260,6 +263,12 @@ static uint8_t bytes[MAX_LORA_PAYLOAD_LEN + 1]; // Global LoRa radio type LoRaRadioType radioType = NO_RADIO; +static uint32_t packetTimeForCodingRate(uint32_t packetLen, uint8_t codingRate, void *context) +{ + auto *radio = static_cast(context); + return radio ? radio->getPacketTimeForCodingRate(packetLen, codingRate) : 0; +} + extern RadioLibHal *RadioLibHAL; #if defined(HW_SPI1_DEVICE) && defined(ARCH_ESP32) extern SPIClass SPI1; @@ -580,14 +589,93 @@ float getEffectiveDutyCycle() uint32_t RadioInterface::getPacketTime(const meshtastic_MeshPacket *p, bool received) { - uint32_t pl = 0; + uint32_t pl = getPacketLength(p); + return getPacketTime(pl, received); +} + +uint32_t RadioInterface::getPacketLength(const meshtastic_MeshPacket *p) const +{ if (p->which_payload_variant == meshtastic_MeshPacket_encrypted_tag) { - pl = p->encrypted.size + sizeof(PacketHeader); - } else { - size_t numbytes = pb_encode_to_bytes(bytes, sizeof(bytes), &meshtastic_Data_msg, &p->decoded); - pl = numbytes + sizeof(PacketHeader); + return p->encrypted.size + sizeof(PacketHeader); } - return getPacketTime(pl, received); + + size_t numbytes = pb_encode_to_bytes(bytes, sizeof(bytes), &meshtastic_Data_msg, &p->decoded); + return numbytes + sizeof(PacketHeader); +} + +bool RadioInterface::setActiveCodingRate(uint8_t codingRate) +{ + if (codingRate < 5 || codingRate > 8) + return false; + + // The base interface cannot touch hardware, so only a restore to the + // configured static CR can be considered successful here. Radio backends + // that can really retune CR override this method. + if (codingRate != cr) + return false; + + activeCr = cr; + return true; +} + +void RadioInterface::chooseCodingRateForPacket(meshtastic_MeshPacket *p, size_t queueDepth) +{ + if (!airtimePolicy || !p) + return; + + DcrSettings settings = airtimePolicy->settingsFromConfig(config.lora); + if (settings.mode == meshtastic_Config_LoRaConfig_DynamicCodingRateMode_DCR_OFF) + return; + + uint32_t packetLen = getPacketLength(p); + meshtastic_PortNum portnum = meshtastic_PortNum_UNKNOWN_APP; + bool classKnown = false; + // Classification here may only see encrypted/header-only state. For + // local-origin packets, AirtimePolicy can replace it with the pre-encryption + // class cached by Router::send(). + DcrPacketClass packetClass = airtimePolicy->classifyPacket(*p, &portnum, &classKnown); + DcrRetryContext retry = airtimePolicy->getRetryContext(*p); + bool localOrigin = isFromUs(p); + bool relay = !localOrigin; + + ChannelAirtimeStats channel = {.channelUtilizationPercent = airTime ? airTime->channelUtilizationPercent() : 0.0f, + .txUtilizationPercent = airTime ? airTime->utilizationTXPercent() : 0.0f, + .dutyCyclePercent = myRegion ? myRegion->dutyCycle : 100.0f, + .queueDepth = static_cast(std::min(queueDepth, UINT8_MAX))}; + + DcrPacketContext ctx = {.packet = p, + .portnum = portnum, + .packetClass = packetClass, + .priority = p->priority, + .baseCr = getBaseCodingRate(), + .packetLen = packetLen, + .predictedAirtimeMs = getPacketTimeForCodingRate(packetLen, getBaseCodingRate()), + .retry = retry, + .classKnown = classKnown, + .localOrigin = localOrigin, + .relay = relay, + .lateRelay = relay && p->tx_after != 0, + .lastHop = relay && p->hop_limit <= 1, + .direct = p->hop_start != 0 && p->hop_start == p->hop_limit, + .rxSnr = p->rx_snr, + .rxRssi = p->rx_rssi, + .nowMsec = millis()}; + + DcrDecision decision = airtimePolicy->choose(ctx, channel, settings, packetTimeForCodingRate, this); + bool urgent = decision.packetClass == DcrPacketClass::Urgent; + if (!setActiveCodingRate(decision.cr)) { + LOG_WARN("DCR failed to set cr=%u, using base cr=%u", decision.cr, getBaseCodingRate()); + decision.cr = getBaseCodingRate(); + decision.predictedAirtimeMs = getPacketTimeForCodingRate(packetLen, decision.cr); + } + + // Record the actual CR selected for this packet. It is physical-layer + // metadata carried by the LoRa explicit header, so it must not be copied + // into MeshPacket just for later accounting. + airtimePolicy->observeTxStart(*p, decision.cr, decision.predictedAirtimeMs, urgent, millis()); + LOG_DEBUG("DCR tx cr=%u class=%u reason=0x%08x util=%.1f%% q=%u retry=%u toa=%ums", decision.cr, + static_cast(decision.packetClass), decision.reasonFlags, channel.channelUtilizationPercent, + channel.queueDepth, retry.attempt, decision.predictedAirtimeMs); } /** The delay to use for retransmitting dropped packets */ @@ -1119,6 +1207,7 @@ void RadioInterface::applyModemConfig() LOG_INFO("channel_num: %d", channel_num + 1); LOG_INFO("frequency: %f", getFreq()); LOG_INFO("Slot time: %u msec, preamble time: %u msec", slotTimeMsec, preambleTimeMsec); + activeCr = cr; } // end of applyModemConfig /** Slottime is the time to detect a transmission has started, consisting of: @@ -1233,4 +1322,4 @@ size_t RadioInterface::beginSending(meshtastic_MeshPacket *p) sendingPacket = p; return p->encrypted.size + sizeof(PacketHeader); -} \ No newline at end of file +} diff --git a/src/mesh/RadioInterface.h b/src/mesh/RadioInterface.h index a1c692e24b2..cd5a3d04f67 100644 --- a/src/mesh/RadioInterface.h +++ b/src/mesh/RadioInterface.h @@ -91,6 +91,7 @@ class RadioInterface float bw = 125; uint8_t sf = 9; uint8_t cr = 5; + uint8_t activeCr = 5; static constexpr uint8_t NUM_SYM_CAD = 2; // Number of symbols used for CAD, 2 is the default since RadioLib 6.3.0 as per AN1200.48 @@ -222,6 +223,13 @@ class RadioInterface */ [[nodiscard]] uint32_t getPacketTime(const meshtastic_MeshPacket *p, bool received = false); [[nodiscard]] virtual uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) = 0; + [[nodiscard]] uint32_t getPacketLength(const meshtastic_MeshPacket *p) const; + [[nodiscard]] virtual uint32_t getPacketTimeForCodingRate(uint32_t totalPacketLen, uint8_t codingRate) = 0; + + [[nodiscard]] uint8_t getBaseCodingRate() const { return cr; } + [[nodiscard]] uint8_t getActiveCodingRate() const { return activeCr; } + virtual bool setActiveCodingRate(uint8_t codingRate); + virtual bool restoreBaseCodingRate() { return setActiveCodingRate(cr); } /** * Get the channel we saved. @@ -267,6 +275,15 @@ class RadioInterface */ [[nodiscard]] size_t beginSending(meshtastic_MeshPacket *p); + /** + * Pick and apply the per-packet coding rate immediately before transmission. + * + * Keeping this in the common radio layer lets RadioLib radios and the + * Portduino simulator share one DCR flow instead of growing backend-local + * heuristics that diverge over time. + */ + void chooseCodingRateForPacket(meshtastic_MeshPacket *p, size_t queueDepth); + /** * Some regulatory regions limit xmit power. * This function should be called by subclasses after setting their desired power. It might lower it diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index ef9a17f8d41..f1e8a6c1510 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -1,4 +1,5 @@ #include "RadioLibInterface.h" +#include "AirtimePolicy.h" #include "MeshTypes.h" #include "NodeDB.h" #include "PowerMon.h" @@ -15,6 +16,7 @@ #include "PortduinoGlue.h" #include "meshUtils.h" #endif + void LockingArduinoHal::spiBeginTransaction() { spiLock->lock(); @@ -415,6 +417,7 @@ void RadioLibInterface::handleTransmitInterrupt() // ignore the transmit interrupt if (sendingPacket) completeSending(); + restoreBaseCodingRate(); powerMon->clearState(meshtastic_PowerMon_State_Lora_TXOn); // But our transmitter is definitely off now } @@ -520,6 +523,24 @@ void RadioLibInterface::handleReceiveInterrupt() mp->relay_node = mp->hop_start == 0 ? NO_RELAY_NODE : radioBuffer.header.relay_node; addReceiveMetadata(mp); + if (airtimePolicy) { + DcrSettings settings = airtimePolicy->settingsFromConfig(config.lora); + if (settings.mode != meshtastic_Config_LoRaConfig_DynamicCodingRateMode_DCR_OFF) { + // In explicit-header mode the radio header tells us the CR + // used on this hop. Store it as local observation only; the + // Meshtastic packet payload remains unchanged. + airtimePolicy->observeRx({.from = mp->from, + .relayNode = mp->relay_node, + .hopStart = mp->hop_start, + .hopLimit = mp->hop_limit, + .rxCr = lastRxCodingRate, + .airtimeMs = rxMsec, + .snr = mp->rx_snr, + .rssi = mp->rx_rssi, + .nowMsec = millis()}, + settings.trackNeighborCr); + } + } mp->which_payload_variant = meshtastic_MeshPacket_encrypted_tag; // Mark that the payload is still encrypted at this point @@ -596,6 +617,10 @@ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp) packetPool.release(txp); return false; } else { + // Choose CR as late as possible so queued packets see fresh channel + // pressure. The call is kept cheap because it runs after CAD/CSMA delay + // and just before the radio starts transmitting. + chooseCodingRateForPacket(txp, txQueue.size()); configHardwareForSend(); // must be after setStandby size_t numbytes = beginSending(txp); @@ -607,6 +632,7 @@ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp) // This send failed, but make sure to 'complete' it properly completeSending(); + restoreBaseCodingRate(); powerMon->clearState(meshtastic_PowerMon_State_Lora_TXOn); // Transmitter off now startReceive(); // Restart receive mode (because startTransmit failed to put us in xmit mode) } else { diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h index 5b850d67578..57453a051fb 100644 --- a/src/mesh/RadioLibInterface.h +++ b/src/mesh/RadioLibInterface.h @@ -219,6 +219,7 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified protected: uint32_t activeReceiveStart = 0; + uint8_t lastRxCodingRate = 0; bool receiveDetected(uint16_t irq, unsigned long syncWordHeaderValidFlag, unsigned long preambleDetectedFlag); @@ -259,11 +260,8 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified bool hasCRC; lora.getLoRaRxHeaderInfo(&rxCR, &hasCRC); // Go from raw header value to denominator - if (rxCR < 5) { - rxCR += 4; - } else if (rxCR == 7) { - rxCR = 8; - } + rxCR = normalizeRxCodingRate(rxCR); + lastRxCodingRate = rxCR; // Received packet configuration must be the same as configured, except for coding rate and CRC DataRate_t dr = getDataRate(); @@ -278,6 +276,23 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified return lora.getTimeOnAir(pl) / 1000; } + template uint32_t computePacketTimeForCodingRate(T &lora, uint32_t pl, uint8_t codingRate) + { + DataRate_t dr = getDataRate(); + dr.lora.codingRate = codingRate; + return lora.calculateTimeOnAir(modemType, dr, getPacketConfig(), pl) / 1000; + } + + static uint8_t normalizeRxCodingRate(uint8_t rxCR) + { + if (rxCR < 5) { + rxCR += 4; + } else if (rxCR == 7) { + rxCR = 8; + } + return rxCR; + } + const char *radioLibErr = "RadioLib err="; /** diff --git a/src/mesh/ReliableRouter.cpp b/src/mesh/ReliableRouter.cpp index a1cfb1200ca..b41e49323d5 100644 --- a/src/mesh/ReliableRouter.cpp +++ b/src/mesh/ReliableRouter.cpp @@ -152,7 +152,9 @@ void ReliableRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtas if (ackId) { stopRetransmission(p->to, ackId); } else { - stopRetransmission(p->to, nakId); + // A NAK is a completed reliable exchange, but it is not a TX + // success for DCR's per-CR delivery accounting. + stopRetransmission(p->to, nakId, false); } } } @@ -191,4 +193,4 @@ bool ReliableRouter::shouldSuccessAckWithWantAck(const meshtastic_MeshPacket *p) } return false; -} \ No newline at end of file +} diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 6cecf4a0e26..2cbf2186890 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -1,4 +1,5 @@ #include "Router.h" +#include "AirtimePolicy.h" #include "Channels.h" #include "CryptoEngine.h" #include "MeshRadio.h" @@ -375,6 +376,11 @@ ErrorCode Router::send(meshtastic_MeshPacket *p) return meshtastic_Routing_Error_BAD_REQUEST; } + // DCR needs portnum/priority while payload is still decoded, but selected + // CR is physical-layer metadata and does not belong in MeshPacket. + if (airtimePolicy && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) + airtimePolicy->rememberPacketClass(*p, millis()); + // If the packet is not yet encrypted, do so now if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) { ChannelIndex chIndex = p->channel; // keep as a local because we are about to change it diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index d499e4a8c6d..352c26be779 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -265,6 +265,24 @@ template bool SX126xInterface::reconfigure() return true; } +template bool SX126xInterface::setActiveCodingRate(uint8_t codingRate) +{ + if (codingRate < 5 || codingRate > 8) + return false; + + if (codingRate == activeCr) + return true; + + int err = lora.setCodingRate(codingRate); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX126X set DCR coding rate %s%d", radioLibErr, err); + return false; + } + + activeCr = codingRate; + return true; +} + template void SX126xInterface::disableInterrupt() { lora.clearDio1Action(); @@ -480,4 +498,4 @@ template void SX126xInterface::setTransmitEnable(bool txon) #endif } -#endif \ No newline at end of file +#endif diff --git a/src/mesh/SX126xInterface.h b/src/mesh/SX126xInterface.h index 67625e1154a..803916214ce 100644 --- a/src/mesh/SX126xInterface.h +++ b/src/mesh/SX126xInterface.h @@ -75,9 +75,14 @@ template class SX126xInterface : public RadioLibInterface virtual void setStandby() override; uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } + uint32_t getPacketTimeForCodingRate(uint32_t pl, uint8_t codingRate) override + { + return computePacketTimeForCodingRate(lora, pl, codingRate); + } + bool setActiveCodingRate(uint8_t codingRate) override; private: /** Some boards require GPIO control of tx vs rx paths */ void setTransmitEnable(bool txon); }; -#endif \ No newline at end of file +#endif diff --git a/src/mesh/SX128xInterface.cpp b/src/mesh/SX128xInterface.cpp index 64d71921a58..8aa5a86d035 100644 --- a/src/mesh/SX128xInterface.cpp +++ b/src/mesh/SX128xInterface.cpp @@ -156,6 +156,34 @@ template bool SX128xInterface::reconfigure() return true; } +template bool SX128xInterface::setActiveCodingRate(uint8_t codingRate) +{ + if (codingRate < 5 || codingRate > 8) + return false; + + // Keep the preset's long-interleaving behavior while changing only the CR. + int err = lora.setCodingRate(codingRate, codingRate != 7); + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX128X set DCR coding rate %s%d", radioLibErr, err); + return false; + } + + activeCr = codingRate; + return true; +} + +template bool SX128xInterface::restoreBaseCodingRate() +{ + int err = lora.setCodingRate(cr, cr != 7); // use long interleaving except if CR is 4/7 which doesn't support it + if (err != RADIOLIB_ERR_NONE) { + LOG_ERROR("SX128X restore coding rate %s%d", radioLibErr, err); + return false; + } + + activeCr = cr; + return true; +} + template void SX128xInterface::disableInterrupt() { lora.clearDio1Action(); @@ -325,4 +353,4 @@ template bool SX128xInterface::sleep() return true; } -#endif \ No newline at end of file +#endif diff --git a/src/mesh/SX128xInterface.h b/src/mesh/SX128xInterface.h index acdcbbb27c8..b5874b3cd99 100644 --- a/src/mesh/SX128xInterface.h +++ b/src/mesh/SX128xInterface.h @@ -69,4 +69,10 @@ template class SX128xInterface : public RadioLibInterface virtual void setStandby() override; uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } + uint32_t getPacketTimeForCodingRate(uint32_t pl, uint8_t codingRate) override + { + return computePacketTimeForCodingRate(lora, pl, codingRate); + } + bool setActiveCodingRate(uint8_t codingRate) override; + bool restoreBaseCodingRate() override; }; diff --git a/src/mesh/generated/meshtastic/apponly.pb.h b/src/mesh/generated/meshtastic/apponly.pb.h index 88cbcb5e67b..5a3306d4839 100644 --- a/src/mesh/generated/meshtastic/apponly.pb.h +++ b/src/mesh/generated/meshtastic/apponly.pb.h @@ -55,7 +55,7 @@ extern const pb_msgdesc_t meshtastic_ChannelSet_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_APPONLY_PB_H_MAX_SIZE meshtastic_ChannelSet_size -#define meshtastic_ChannelSet_size 685 +#define meshtastic_ChannelSet_size 688 #ifdef __cplusplus } /* extern "C" */ diff --git a/src/mesh/generated/meshtastic/config.pb.cpp b/src/mesh/generated/meshtastic/config.pb.cpp index c554ca43ce9..1c549894c23 100644 --- a/src/mesh/generated/meshtastic/config.pb.cpp +++ b/src/mesh/generated/meshtastic/config.pb.cpp @@ -37,38 +37,3 @@ PB_BIND(meshtastic_Config_SecurityConfig, meshtastic_Config_SecurityConfig, AUTO PB_BIND(meshtastic_Config_SessionkeyConfig, meshtastic_Config_SessionkeyConfig, AUTO) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/mesh/generated/meshtastic/config.pb.h b/src/mesh/generated/meshtastic/config.pb.h index 820bb276450..c6cd5d790ae 100644 --- a/src/mesh/generated/meshtastic/config.pb.h +++ b/src/mesh/generated/meshtastic/config.pb.h @@ -357,6 +357,13 @@ typedef enum _meshtastic_Config_LoRaConfig_FEM_LNA_Mode { meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT = 2 } meshtastic_Config_LoRaConfig_FEM_LNA_Mode; +typedef enum _meshtastic_Config_LoRaConfig_DynamicCodingRateMode { + /* Keep using the configured static coding rate. */ + meshtastic_Config_LoRaConfig_DynamicCodingRateMode_DCR_OFF = 0, + /* Select LoRa coding rate per packet using local airtime policy. */ + meshtastic_Config_LoRaConfig_DynamicCodingRateMode_DCR_ON = 1 +} meshtastic_Config_LoRaConfig_DynamicCodingRateMode; + typedef enum _meshtastic_Config_BluetoothConfig_PairingMode { /* Device generates a random PIN that will be shown on the screen of the device for pairing */ meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN = 0, @@ -622,6 +629,9 @@ typedef struct _meshtastic_Config_LoRaConfig { meshtastic_Config_LoRaConfig_FEM_LNA_Mode fem_lna_mode; /* Don't use radiolib to initialize the radio, instead listen for a serialHal connection */ bool serial_hal_only; + /* Dynamic Coding Rate policy mode. DCR_OFF keeps the configured static coding rate; DCR_ON applies + per-packet CR selection. */ + meshtastic_Config_LoRaConfig_DynamicCodingRateMode dcr_mode; } meshtastic_Config_LoRaConfig; typedef struct _meshtastic_Config_BluetoothConfig { @@ -745,6 +755,10 @@ extern "C" { #define _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MAX meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT #define _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_ARRAYSIZE ((meshtastic_Config_LoRaConfig_FEM_LNA_Mode)(meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT+1)) +#define _meshtastic_Config_LoRaConfig_DynamicCodingRateMode_MIN meshtastic_Config_LoRaConfig_DynamicCodingRateMode_DCR_OFF +#define _meshtastic_Config_LoRaConfig_DynamicCodingRateMode_MAX meshtastic_Config_LoRaConfig_DynamicCodingRateMode_DCR_ON +#define _meshtastic_Config_LoRaConfig_DynamicCodingRateMode_ARRAYSIZE ((meshtastic_Config_LoRaConfig_DynamicCodingRateMode)(meshtastic_Config_LoRaConfig_DynamicCodingRateMode_DCR_ON+1)) + #define _meshtastic_Config_BluetoothConfig_PairingMode_MIN meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN #define _meshtastic_Config_BluetoothConfig_PairingMode_MAX meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN #define _meshtastic_Config_BluetoothConfig_PairingMode_ARRAYSIZE ((meshtastic_Config_BluetoothConfig_PairingMode)(meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN+1)) @@ -769,6 +783,7 @@ extern "C" { #define meshtastic_Config_LoRaConfig_modem_preset_ENUMTYPE meshtastic_Config_LoRaConfig_ModemPreset #define meshtastic_Config_LoRaConfig_region_ENUMTYPE meshtastic_Config_LoRaConfig_RegionCode #define meshtastic_Config_LoRaConfig_fem_lna_mode_ENUMTYPE meshtastic_Config_LoRaConfig_FEM_LNA_Mode +#define meshtastic_Config_LoRaConfig_dcr_mode_ENUMTYPE meshtastic_Config_LoRaConfig_DynamicCodingRateMode #define meshtastic_Config_BluetoothConfig_mode_ENUMTYPE meshtastic_Config_BluetoothConfig_PairingMode @@ -783,7 +798,7 @@ extern "C" { #define meshtastic_Config_NetworkConfig_init_default {0, "", "", "", 0, _meshtastic_Config_NetworkConfig_AddressMode_MIN, false, meshtastic_Config_NetworkConfig_IpV4Config_init_default, "", 0, 0} #define meshtastic_Config_NetworkConfig_IpV4Config_init_default {0, 0, 0, 0} #define meshtastic_Config_DisplayConfig_init_default {0, _meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_MIN, 0, 0, 0, _meshtastic_Config_DisplayConfig_DisplayUnits_MIN, _meshtastic_Config_DisplayConfig_OledType_MIN, _meshtastic_Config_DisplayConfig_DisplayMode_MIN, 0, 0, _meshtastic_Config_DisplayConfig_CompassOrientation_MIN, 0, 0, 0} -#define meshtastic_Config_LoRaConfig_init_default {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN, 0} +#define meshtastic_Config_LoRaConfig_init_default {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN, 0, _meshtastic_Config_LoRaConfig_DynamicCodingRateMode_MIN} #define meshtastic_Config_BluetoothConfig_init_default {0, _meshtastic_Config_BluetoothConfig_PairingMode_MIN, 0} #define meshtastic_Config_SecurityConfig_init_default {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0} #define meshtastic_Config_SessionkeyConfig_init_default {0} @@ -794,7 +809,7 @@ extern "C" { #define meshtastic_Config_NetworkConfig_init_zero {0, "", "", "", 0, _meshtastic_Config_NetworkConfig_AddressMode_MIN, false, meshtastic_Config_NetworkConfig_IpV4Config_init_zero, "", 0, 0} #define meshtastic_Config_NetworkConfig_IpV4Config_init_zero {0, 0, 0, 0} #define meshtastic_Config_DisplayConfig_init_zero {0, _meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_MIN, 0, 0, 0, _meshtastic_Config_DisplayConfig_DisplayUnits_MIN, _meshtastic_Config_DisplayConfig_OledType_MIN, _meshtastic_Config_DisplayConfig_DisplayMode_MIN, 0, 0, _meshtastic_Config_DisplayConfig_CompassOrientation_MIN, 0, 0, 0} -#define meshtastic_Config_LoRaConfig_init_zero {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN, 0} +#define meshtastic_Config_LoRaConfig_init_zero {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN, 0, _meshtastic_Config_LoRaConfig_DynamicCodingRateMode_MIN} #define meshtastic_Config_BluetoothConfig_init_zero {0, _meshtastic_Config_BluetoothConfig_PairingMode_MIN, 0} #define meshtastic_Config_SecurityConfig_init_zero {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0} #define meshtastic_Config_SessionkeyConfig_init_zero {0} @@ -882,6 +897,7 @@ extern "C" { #define meshtastic_Config_LoRaConfig_config_ok_to_mqtt_tag 105 #define meshtastic_Config_LoRaConfig_fem_lna_mode_tag 106 #define meshtastic_Config_LoRaConfig_serial_hal_only_tag 107 +#define meshtastic_Config_LoRaConfig_dcr_mode_tag 108 #define meshtastic_Config_BluetoothConfig_enabled_tag 1 #define meshtastic_Config_BluetoothConfig_mode_tag 2 #define meshtastic_Config_BluetoothConfig_fixed_pin_tag 3 @@ -1035,7 +1051,8 @@ X(a, STATIC, REPEATED, UINT32, ignore_incoming, 103) \ X(a, STATIC, SINGULAR, BOOL, ignore_mqtt, 104) \ X(a, STATIC, SINGULAR, BOOL, config_ok_to_mqtt, 105) \ X(a, STATIC, SINGULAR, UENUM, fem_lna_mode, 106) \ -X(a, STATIC, SINGULAR, BOOL, serial_hal_only, 107) +X(a, STATIC, SINGULAR, BOOL, serial_hal_only, 107) \ +X(a, STATIC, SINGULAR, UENUM, dcr_mode, 108) #define meshtastic_Config_LoRaConfig_CALLBACK NULL #define meshtastic_Config_LoRaConfig_DEFAULT NULL @@ -1092,14 +1109,14 @@ extern const pb_msgdesc_t meshtastic_Config_SessionkeyConfig_msg; #define meshtastic_Config_BluetoothConfig_size 10 #define meshtastic_Config_DeviceConfig_size 100 #define meshtastic_Config_DisplayConfig_size 36 -#define meshtastic_Config_LoRaConfig_size 91 +#define meshtastic_Config_LoRaConfig_size 94 #define meshtastic_Config_NetworkConfig_IpV4Config_size 20 #define meshtastic_Config_NetworkConfig_size 204 #define meshtastic_Config_PositionConfig_size 62 #define meshtastic_Config_PowerConfig_size 52 #define meshtastic_Config_SecurityConfig_size 178 #define meshtastic_Config_SessionkeyConfig_size 0 -#define meshtastic_Config_size 207 +#define meshtastic_Config_size 210 #ifdef __cplusplus } /* extern "C" */ diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index 7c14c3e0fbc..60670c416dc 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -446,7 +446,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; /* Maximum encoded size of messages (where known) */ /* meshtastic_NodeDatabase_size depends on runtime parameters */ #define MESHTASTIC_MESHTASTIC_DEVICEONLY_PB_H_MAX_SIZE meshtastic_BackupPreferences_size -#define meshtastic_BackupPreferences_size 2432 +#define meshtastic_BackupPreferences_size 2435 #define meshtastic_ChannelFile_size 718 #define meshtastic_DeviceState_size 1944 #define meshtastic_NodeEnvironmentEntry_size 170 diff --git a/src/mesh/generated/meshtastic/localonly.pb.h b/src/mesh/generated/meshtastic/localonly.pb.h index 27f5ad7bfdf..27bb44bf04a 100644 --- a/src/mesh/generated/meshtastic/localonly.pb.h +++ b/src/mesh/generated/meshtastic/localonly.pb.h @@ -205,7 +205,7 @@ extern const pb_msgdesc_t meshtastic_LocalModuleConfig_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_LOCALONLY_PB_H_MAX_SIZE meshtastic_LocalModuleConfig_size -#define meshtastic_LocalConfig_size 757 +#define meshtastic_LocalConfig_size 760 #define meshtastic_LocalModuleConfig_size 820 #ifdef __cplusplus diff --git a/src/platform/portduino/SimRadio.cpp b/src/platform/portduino/SimRadio.cpp index 6e7fe24cbaf..e9de4123c34 100644 --- a/src/platform/portduino/SimRadio.cpp +++ b/src/platform/portduino/SimRadio.cpp @@ -78,6 +78,7 @@ void SimRadio::handleTransmitInterrupt() // ignore the transmit interrupt if (sendingPacket) completeSending(); + restoreBaseCodingRate(); isReceiving = true; if (receivingPacket) // This happens when we don't consider something a collision if we weren't sending long enough @@ -207,6 +208,7 @@ void SimRadio::startSend(meshtastic_MeshPacket *txp) { printPacket("Start low level send", txp); isReceiving = false; + chooseCodingRateForPacket(txp, txQueue.size()); size_t numbytes = beginSending(txp); meshtastic_MeshPacket *p = packetPool.allocCopy(*txp); perhapsDecode(p); @@ -321,12 +323,6 @@ void SimRadio::handleReceiveInterrupt() deliverToReceiver(mp); } -size_t SimRadio::getPacketLength(meshtastic_MeshPacket *mp) -{ - auto &p = mp->decoded; - return (size_t)p.payload.size + sizeof(PacketHeader); -} - int16_t SimRadio::readData(uint8_t *data, size_t len) { int16_t state = RADIOLIB_ERR_NONE; @@ -347,6 +343,26 @@ int16_t SimRadio::readData(uint8_t *data, size_t len) * @return num msecs for the packet */ uint32_t SimRadio::getPacketTime(uint32_t pl, bool received) +{ + (void)received; + return computePacketTime(pl, activeCr); +} + +uint32_t SimRadio::getPacketTimeForCodingRate(uint32_t pl, uint8_t codingRate) +{ + return computePacketTime(pl, codingRate); +} + +bool SimRadio::setActiveCodingRate(uint8_t codingRate) +{ + if (codingRate < 5 || codingRate > 8) + return false; + + activeCr = codingRate; + return true; +} + +uint32_t SimRadio::computePacketTime(uint32_t pl, uint8_t codingRate) const { float bandwidthHz = bw * 1000.0f; bool headDisable = false; // we currently always use the header @@ -356,10 +372,10 @@ uint32_t SimRadio::getPacketTime(uint32_t pl, bool received) float tPreamble = (preambleLength + 4.25f) * tSym; float numPayloadSym = - 8 + max(ceilf(((8.0f * pl - 4 * sf + 28 + 16 - 20 * headDisable) / (4 * (sf - 2 * lowDataOptEn))) * cr), 0.0f); + 8 + max(ceilf(((8.0f * pl - 4 * sf + 28 + 16 - 20 * headDisable) / (4 * (sf - 2 * lowDataOptEn))) * codingRate), 0.0f); float tPayload = numPayloadSym * tSym; float tPacket = tPreamble + tPayload; uint32_t msecs = tPacket * 1000; return msecs; -} \ No newline at end of file +} diff --git a/src/platform/portduino/SimRadio.h b/src/platform/portduino/SimRadio.h index 6f80989da6f..5e017460e17 100644 --- a/src/platform/portduino/SimRadio.h +++ b/src/platform/portduino/SimRadio.h @@ -75,9 +75,6 @@ class SimRadio : public RadioInterface, protected concurrency::NotifiedWorkerThr // start an immediate transmit virtual void startSend(meshtastic_MeshPacket *txp); - // derive packet length - size_t getPacketLength(meshtastic_MeshPacket *p); - int16_t readData(uint8_t *str, size_t len); meshtastic_MeshPacket *receivingPacket = nullptr; // The packet we are currently receiving @@ -91,6 +88,10 @@ class SimRadio : public RadioInterface, protected concurrency::NotifiedWorkerThr void completeSending(); virtual uint32_t getPacketTime(uint32_t pl, bool received = false) override; + virtual uint32_t getPacketTimeForCodingRate(uint32_t pl, uint8_t codingRate) override; + virtual bool setActiveCodingRate(uint8_t codingRate) override; + + uint32_t computePacketTime(uint32_t pl, uint8_t codingRate) const; }; -extern SimRadio *simRadio; \ No newline at end of file +extern SimRadio *simRadio; diff --git a/test/test_dynamic_coding_rate/test_main.cpp b/test/test_dynamic_coding_rate/test_main.cpp new file mode 100644 index 00000000000..3f31ee1e3e5 --- /dev/null +++ b/test/test_dynamic_coding_rate/test_main.cpp @@ -0,0 +1,356 @@ +#include "AirtimePolicy.h" +#include "TestUtil.h" + +#include + +static uint32_t fakeAirtime(uint32_t packetLen, uint8_t codingRate, void *) +{ + return packetLen * 2 + codingRate * 100; +} + +static meshtastic_MeshPacket makePacket(meshtastic_PortNum portnum, + meshtastic_MeshPacket_Priority priority = meshtastic_MeshPacket_Priority_UNSET, + bool wantAck = false) +{ + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; + packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + packet.decoded.portnum = portnum; + packet.priority = priority; + packet.want_ack = wantAck; + packet.from = 0x12345678; + packet.to = 0x87654321; + packet.id = 42; + packet.hop_start = 3; + packet.hop_limit = 3; + return packet; +} + +static DcrPacketContext makeContext(const AirtimePolicy &policy, const meshtastic_MeshPacket &packet, + const DcrRetryContext &retry = {}) +{ + meshtastic_PortNum portnum = meshtastic_PortNum_UNKNOWN_APP; + bool classKnown = false; + DcrPacketClass packetClass = policy.classifyPacket(packet, &portnum, &classKnown); + + return {.packet = &packet, + .portnum = portnum, + .packetClass = packetClass, + .priority = packet.priority, + .baseCr = DCR_CR_SLIM, + .packetLen = 20, + .predictedAirtimeMs = fakeAirtime(20, DCR_CR_SLIM, nullptr), + .retry = retry, + .classKnown = classKnown, + .localOrigin = true, + .relay = false, + .lateRelay = false, + .lastHop = false, + .direct = true, + .rxSnr = 0.0f, + .rxRssi = 0, + .nowMsec = 1000}; +} + +static ChannelAirtimeStats idleChannel() +{ + return {.channelUtilizationPercent = 1.0f, .txUtilizationPercent = 0.1f, .dutyCyclePercent = 100.0f, .queueDepth = 0}; +} + +static ChannelAirtimeStats congestedChannel() +{ + return {.channelUtilizationPercent = 30.0f, .txUtilizationPercent = 8.0f, .dutyCyclePercent = 100.0f, .queueDepth = 8}; +} + +static ChannelAirtimeStats busyChannel() +{ + return {.channelUtilizationPercent = 15.0f, .txUtilizationPercent = 3.0f, .dutyCyclePercent = 100.0f, .queueDepth = 0}; +} + +static DcrSettings onSettings() +{ + DcrSettings settings; + settings.mode = meshtastic_Config_LoRaConfig_DynamicCodingRateMode_DCR_ON; + return settings; +} + +static DcrDecision choose(AirtimePolicy &policy, const meshtastic_MeshPacket &packet, const ChannelAirtimeStats &channel, + const DcrSettings &settings, const DcrRetryContext &retry = {}) +{ + DcrPacketContext ctx = makeContext(policy, packet, retry); + return policy.choose(ctx, channel, settings, fakeAirtime, nullptr); +} + +static void test_settings_default_to_off_with_tuned_policy_defaults() +{ + AirtimePolicy policy; + meshtastic_Config_LoRaConfig config = meshtastic_Config_LoRaConfig_init_zero; + + DcrSettings settings = policy.settingsFromConfig(config); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_DynamicCodingRateMode_DCR_OFF, settings.mode); + TEST_ASSERT_EQUAL_UINT8(DCR_CR_SLIM, settings.minCr); + TEST_ASSERT_EQUAL_UINT8(DCR_CR_RESCUE, settings.maxCr); + TEST_ASSERT_EQUAL_UINT8(10, settings.robustAirtimePct); + TEST_ASSERT_TRUE(settings.trackNeighborCr); + TEST_ASSERT_EQUAL_UINT8(DCR_CR_NORMAL, settings.telemetryMaxCr); + TEST_ASSERT_EQUAL_UINT8(DCR_CR_SLIM, settings.userMinCr); + TEST_ASSERT_EQUAL_UINT8(DCR_CR_ROBUST, settings.alertMinCr); +} + +static void test_telemetry_uses_compact_cr_when_congested() +{ + AirtimePolicy policy; + DcrSettings settings = onSettings(); + auto packet = makePacket(meshtastic_PortNum_TELEMETRY_APP); + + DcrDecision decision = choose(policy, packet, congestedChannel(), settings); + + TEST_ASSERT_EQUAL_UINT8(DCR_CR_SLIM, decision.cr); + TEST_ASSERT_BITS_HIGH(DCR_REASON_CONGESTED, decision.reasonFlags); +} + +static void test_idle_telemetry_is_not_rescue_by_default() +{ + AirtimePolicy policy; + DcrSettings settings = onSettings(); + auto packet = makePacket(meshtastic_PortNum_TELEMETRY_APP); + + DcrDecision decision = choose(policy, packet, idleChannel(), settings); + + TEST_ASSERT_EQUAL_UINT8(DCR_CR_NORMAL, decision.cr); +} + +static void test_idle_text_uses_robust_cr() +{ + AirtimePolicy policy; + DcrSettings settings = onSettings(); + auto packet = makePacket(meshtastic_PortNum_TEXT_MESSAGE_APP); + + DcrDecision decision = choose(policy, packet, idleChannel(), settings); + + TEST_ASSERT_EQUAL_UINT8(DCR_CR_ROBUST, decision.cr); +} + +static void test_busy_text_can_use_slim_cr_by_default() +{ + AirtimePolicy policy; + DcrSettings settings = onSettings(); + auto packet = makePacket(meshtastic_PortNum_TEXT_MESSAGE_APP); + + DcrDecision decision = choose(policy, packet, busyChannel(), settings); + + TEST_ASSERT_EQUAL_UINT8(DCR_CR_SLIM, decision.cr); + TEST_ASSERT_BITS_HIGH(DCR_REASON_BUSY, decision.reasonFlags); +} + +static void test_alert_uses_rescue_cr_when_idle() +{ + AirtimePolicy policy; + DcrSettings settings = onSettings(); + auto packet = makePacket(meshtastic_PortNum_ALERT_APP, meshtastic_MeshPacket_Priority_ALERT); + + DcrDecision decision = choose(policy, packet, idleChannel(), settings); + + TEST_ASSERT_EQUAL_UINT8(DCR_CR_RESCUE, decision.cr); +} + +static void test_final_quiet_retry_escalates_text_to_rescue() +{ + AirtimePolicy policy; + DcrSettings settings = onSettings(); + auto packet = makePacket(meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_MeshPacket_Priority_RELIABLE, true); + DcrRetryContext retry = {.attempt = 2, .finalRetry = true, .quietLoss = true}; + + DcrDecision decision = choose(policy, packet, idleChannel(), settings, retry); + + TEST_ASSERT_EQUAL_UINT8(DCR_CR_RESCUE, decision.cr); + TEST_ASSERT_BITS_HIGH(DCR_REASON_FINAL_RETRY, decision.reasonFlags); +} + +static void test_congested_retry_does_not_jump_to_rescue() +{ + AirtimePolicy policy; + DcrSettings settings = onSettings(); + auto packet = makePacket(meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_MeshPacket_Priority_RELIABLE, true); + DcrRetryContext retry = {.attempt = 2, .finalRetry = true, .quietLoss = true}; + + DcrDecision decision = choose(policy, packet, congestedChannel(), settings, retry); + + TEST_ASSERT_LESS_OR_EQUAL_UINT8(DCR_CR_ROBUST, decision.cr); + TEST_ASSERT_BITS_HIGH(DCR_REASON_COLLISION_PRESSURE, decision.reasonFlags); +} + +static void test_busy_final_retry_does_not_take_quiet_loss_bonus() +{ + AirtimePolicy policy; + DcrSettings settings = onSettings(); + auto packet = makePacket(meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_MeshPacket_Priority_RELIABLE, true); + DcrRetryContext retry = {.attempt = 2, .finalRetry = true, .quietLoss = true}; + + DcrDecision decision = choose(policy, packet, busyChannel(), settings, retry); + + TEST_ASSERT_BITS_HIGH(DCR_REASON_BUSY, decision.reasonFlags); + TEST_ASSERT_BITS_HIGH(DCR_REASON_COLLISION_PRESSURE, decision.reasonFlags); + TEST_ASSERT_EQUAL_UINT32(0, decision.reasonFlags & DCR_REASON_FINAL_RETRY); +} + +static void test_duty_cycle_pressure_clamps_alert() +{ + AirtimePolicy policy; + DcrSettings settings = onSettings(); + auto packet = makePacket(meshtastic_PortNum_ALERT_APP, meshtastic_MeshPacket_Priority_ALERT); + ChannelAirtimeStats channel = idleChannel(); + channel.dutyCyclePercent = 1.0f; + channel.txUtilizationPercent = 1.0f; + + DcrDecision decision = choose(policy, packet, channel, settings); + + TEST_ASSERT_EQUAL_UINT8(DCR_CR_ROBUST, decision.cr); + TEST_ASSERT_BITS_HIGH(DCR_REASON_DUTY_CYCLE, decision.reasonFlags); +} + +static void test_token_bucket_clamps_nonurgent_rescue() +{ + AirtimePolicy policy; + DcrSettings settings = onSettings(); + settings.robustAirtimePct = 10; + auto packet = makePacket(meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_MeshPacket_Priority_RELIABLE, true); + policy.observeTxStart(packet, DCR_CR_RESCUE, 1000, false, 1000); + + DcrRetryContext retry = {.attempt = 2, .finalRetry = true, .quietLoss = true}; + DcrDecision decision = choose(policy, packet, idleChannel(), settings, retry); + + TEST_ASSERT_EQUAL_UINT8(DCR_CR_ROBUST, decision.cr); + TEST_ASSERT_BITS_HIGH(DCR_REASON_TOKEN_BUCKET, decision.reasonFlags); +} + +static void test_min_cr_survives_token_bucket_clamp() +{ + AirtimePolicy policy; + DcrSettings settings = onSettings(); + settings.minCr = DCR_CR_RESCUE; + settings.maxCr = DCR_CR_RESCUE; + settings.robustAirtimePct = 10; + auto packet = makePacket(meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_MeshPacket_Priority_RELIABLE, true); + policy.observeTxStart(packet, DCR_CR_RESCUE, 1000, false, 1000); + + DcrRetryContext retry = {.attempt = 2, .finalRetry = true, .quietLoss = true}; + DcrDecision decision = choose(policy, packet, idleChannel(), settings, retry); + + TEST_ASSERT_EQUAL_UINT8(DCR_CR_RESCUE, decision.cr); + TEST_ASSERT_BITS_HIGH(DCR_REASON_TOKEN_BUCKET, decision.reasonFlags); +} + +static void test_min_cr_survives_busy_expendable_clamp() +{ + AirtimePolicy policy; + DcrSettings settings = onSettings(); + settings.minCr = DCR_CR_RESCUE; + settings.maxCr = DCR_CR_RESCUE; + settings.telemetryMaxCr = DCR_CR_RESCUE; + + DcrPacketContext ctx = {.packet = nullptr, + .portnum = meshtastic_PortNum_TELEMETRY_APP, + .packetClass = DcrPacketClass::Expendable, + .priority = meshtastic_MeshPacket_Priority_UNSET, + .baseCr = DCR_CR_SLIM, + .packetLen = 20, + .predictedAirtimeMs = fakeAirtime(20, DCR_CR_SLIM, nullptr), + .retry = {}, + .classKnown = true, + .localOrigin = true, + .relay = false, + .lateRelay = true, + .lastHop = true, + .direct = true, + .rxSnr = -10.0f, + .rxRssi = -110, + .nowMsec = 1000}; + ChannelAirtimeStats channel = idleChannel(); + channel.channelUtilizationPercent = 10.0f; + + DcrDecision decision = policy.choose(ctx, channel, settings, fakeAirtime, nullptr); + + TEST_ASSERT_EQUAL_UINT8(DCR_CR_RESCUE, decision.cr); + TEST_ASSERT_BITS_HIGH(DCR_REASON_BUSY, decision.reasonFlags); +} + +static void test_off_mode_keeps_base_cr() +{ + AirtimePolicy policy; + DcrSettings settings; + settings.mode = meshtastic_Config_LoRaConfig_DynamicCodingRateMode_DCR_OFF; + auto packet = makePacket(meshtastic_PortNum_ALERT_APP, meshtastic_MeshPacket_Priority_ALERT); + + DcrDecision decision = choose(policy, packet, idleChannel(), settings); + + TEST_ASSERT_EQUAL_UINT8(DCR_CR_SLIM, decision.cr); + TEST_ASSERT_EQUAL_UINT32(DCR_REASON_NONE, decision.reasonFlags); +} + +static void test_rx_observation_tracks_direct_sender() +{ + AirtimePolicy policy; + + policy.observeRx({.from = 0x12345678, + .relayNode = NO_RELAY_NODE, + .hopStart = 3, + .hopLimit = 3, + .rxCr = DCR_CR_ROBUST, + .airtimeMs = 420, + .snr = -4.0f, + .rssi = -95, + .nowMsec = 1234}, + true); + + const NeighborCrStats *stats = policy.getNeighborStats(0x12345678); + TEST_ASSERT_NOT_NULL(stats); + TEST_ASSERT_EQUAL_UINT8(DCR_CR_ROBUST, stats->lastRxCr); + TEST_ASSERT_EQUAL_UINT32(1, policy.getCounters().rxCr[AirtimePolicy::crIndex(DCR_CR_ROBUST)]); +} + +static void test_reused_packet_storage_does_not_reuse_stale_class() +{ + AirtimePolicy policy; + DcrSettings settings = onSettings(); + auto packet = makePacket(meshtastic_PortNum_TEXT_MESSAGE_APP); + + policy.rememberPacketClass(packet, 1000); + + packet.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + packet.priority = meshtastic_MeshPacket_Priority_BACKGROUND; + packet.id = 43; + packet.from = 0xabcdef01; + + DcrDecision decision = choose(policy, packet, idleChannel(), settings); + + TEST_ASSERT_EQUAL_UINT8(DCR_CR_NORMAL, decision.cr); + TEST_ASSERT_BITS_HIGH(DCR_REASON_PERIODIC, decision.reasonFlags); + TEST_ASSERT_EQUAL_UINT32(0, decision.reasonFlags & DCR_REASON_USER); +} + +void setup() +{ + delay(10); + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_settings_default_to_off_with_tuned_policy_defaults); + RUN_TEST(test_telemetry_uses_compact_cr_when_congested); + RUN_TEST(test_idle_telemetry_is_not_rescue_by_default); + RUN_TEST(test_idle_text_uses_robust_cr); + RUN_TEST(test_busy_text_can_use_slim_cr_by_default); + RUN_TEST(test_alert_uses_rescue_cr_when_idle); + RUN_TEST(test_final_quiet_retry_escalates_text_to_rescue); + RUN_TEST(test_congested_retry_does_not_jump_to_rescue); + RUN_TEST(test_busy_final_retry_does_not_take_quiet_loss_bonus); + RUN_TEST(test_duty_cycle_pressure_clamps_alert); + RUN_TEST(test_token_bucket_clamps_nonurgent_rescue); + RUN_TEST(test_min_cr_survives_token_bucket_clamp); + RUN_TEST(test_min_cr_survives_busy_expendable_clamp); + RUN_TEST(test_off_mode_keeps_base_cr); + RUN_TEST(test_rx_observation_tracks_direct_sender); + RUN_TEST(test_reused_packet_storage_does_not_reuse_stale_class); + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_radio/test_main.cpp b/test/test_radio/test_main.cpp index a7d3d32d213..aee2ca98a4a 100644 --- a/test/test_radio/test_main.cpp +++ b/test/test_radio/test_main.cpp @@ -28,6 +28,7 @@ class TestableRadioInterface : public RadioInterface // Stubs for pure virtual methods required by RadioInterface uint32_t getPacketTime(uint32_t, bool) override { return 0; } + uint32_t getPacketTimeForCodingRate(uint32_t, uint8_t) override { return 0; } ErrorCode send(meshtastic_MeshPacket *p) override { return ERRNO_OK; } }; diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index 734b4a9702a..deb3401e1f8 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -74,6 +74,13 @@ class MockRadioInterface : public RadioInterface (void)received; return 0; } + + uint32_t getPacketTimeForCodingRate(uint32_t totalPacketLen, uint8_t codingRate) override + { + (void)totalPacketLen; + (void)codingRate; + return 0; + } }; class MockRouter : public Router