diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 5d48f4c67be..a22114b8881 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -e native_kiss_modem -vv + run: pio test -e native -e native_kiss_modem -e native_region_gating -vv - name: Upload Test Results # Upload test results even if the test step failed. diff --git a/dmc-docs/custom-cli.de.md b/dmc-docs/custom-cli.de.md index 4113dbd763b..d03baa79ff9 100644 --- a/dmc-docs/custom-cli.de.md +++ b/dmc-docs/custom-cli.de.md @@ -84,19 +84,25 @@ Kein OTA auf Boards mit 4 MB Flash (ThinkNode M2/M5, Xiao C3, LilyGo T-LoRa V2.1 ## Duty-Cycle-Region-Gating `dc.gate.*`. Standardmäßig **aus**. Wirft Inter-Regions-Flood-Verkehr ab, wenn der -Repeater den größten Teil seines Duty-Cycle-Budgets verbraucht hat. Transient: wird nie in +Repeater einen großen Teil der Zeit sendet. Transient: wird nie in die Regionskonfiguration geschrieben. Läuft vor dem Packet-Filter, daher erscheinen diese Drops nicht in `filter stats`. - `set dc.gate ` / `get dc.gate` (`1`/`0` funktionieren auch) -- `set dc.gate.thresh <1-100>` / `get dc.gate.thresh` (Standard 70) +- `set dc.gate.thresh <1-100>` / `get dc.gate.thresh` (Standard 35) - `set dc.gate.hyst <0-50>` / `get dc.gate.hyst` (Standard 10) -- `get dc.gate.status` (z. B. `duty 74%, gate level 2/4`) - -Schwelle, Hysterese und `duty` sind ein Prozentsatz des **Duty-Cycle-Budgets** -(`set dutycycle`), nicht der Uhrzeit. Mit `dutycycle 10` darf der Repeater 360 Sekunden -pro Stunde senden; `dc.gate.thresh 70` beginnt mit dem Gating, sobald 252 dieser Sekunden -verbraucht sind. Die Schwelle muss also nicht unter dem Duty-Cycle-Limit liegen. +- `get dc.gate.status` (z. B. `duty 41%, gate level 2/4`) + +Schwelle, Hysterese und `duty` sind der **echte TX-Duty-Cycle**: der Anteil der Uhrzeit, +in dem der Repeater in den letzten 60 Sekunden gesendet hat. Das ist nicht dasselbe wie +das Duty-Cycle-Limit (`set dutycycle`): Es gilt pro Stunde, aus einer Sendezeitreserve, +die der Repeater in Spitzen aufbrauchen darf. Mit `dutycycle 10` (360 Sekunden pro +Stunde) bleibt normaler Verkehr deutlich unter 35 %; ein Repeater, der 35 % der Zeit +sendet, hat die Reserve in etwa 24 Minuten aufgebraucht, und das Gating greift vorher +ein. Auf einem 1-%-Subband ist `10`-`15` eine bessere Schwelle. + +Frühere DMC-Builds maßen einen Prozentsatz des Duty-Cycle-Budgets (Standard 70). Dieser +Wert wird nach dem Update nicht übernommen; die Schwelle beginnt dann bei 35. ## Duty-Cycle diff --git a/dmc-docs/custom-cli.en.md b/dmc-docs/custom-cli.en.md index 5f5d185489d..a3dee149faa 100644 --- a/dmc-docs/custom-cli.en.md +++ b/dmc-docs/custom-cli.en.md @@ -82,19 +82,25 @@ No OTA on 4 MB flash boards (ThinkNode M2/M5, Xiao C3, LilyGo T-LoRa V2.1). A ne ## Duty-cycle region gating -`dc.gate.*`. **Off** by default. Sheds inter-region flood traffic when the repeater has -used up most of its duty-cycle budget. Transient: never written to the region config. Runs +`dc.gate.*`. **Off** by default. Sheds inter-region flood traffic when the repeater is +transmitting a large share of the time. Transient: never written to the region config. Runs before the packet filter, so these drops do not appear in `filter stats`. - `set dc.gate ` / `get dc.gate` (`1`/`0` work too) -- `set dc.gate.thresh <1-100>` / `get dc.gate.thresh` (default 70) +- `set dc.gate.thresh <1-100>` / `get dc.gate.thresh` (default 35) - `set dc.gate.hyst <0-50>` / `get dc.gate.hyst` (default 10) -- `get dc.gate.status` (e.g. `duty 74%, gate level 2/4`) - -The threshold, hysteresis and `duty` are a percentage of the **duty-cycle budget** -(`set dutycycle`), not of wall-clock time. With `dutycycle 10` the repeater may transmit -for 360 seconds per hour; `dc.gate.thresh 70` starts gating once 252 of those seconds are -used. The threshold does not need to be below the duty-cycle limit. +- `get dc.gate.status` (e.g. `duty 41%, gate level 2/4`) + +The threshold, hysteresis and `duty` are the **true TX duty cycle**: the share of +wall-clock time the repeater spent transmitting over the last 60 seconds. That is not the +duty-cycle limit (`set dutycycle`): the limit applies per hour, from an airtime reserve the +repeater may spend in bursts. With `dutycycle 10` (360 seconds per hour) normal traffic +stays well below 35%; a repeater transmitting 35% of the time empties the reserve in about +24 minutes, and gating steps in before that. On a 1% sub-band, `10`-`15` is a better +threshold. + +Earlier DMC builds measured a percentage of the duty-cycle budget (default 70). That value +is not carried over by the update; the threshold then starts at 35. ## Duty cycle diff --git a/dmc-docs/custom-cli.md b/dmc-docs/custom-cli.md index 333d03e2755..87be7445610 100644 --- a/dmc-docs/custom-cli.md +++ b/dmc-docs/custom-cli.md @@ -84,19 +84,25 @@ Geen OTA op boards met 4 MB flash (ThinkNode M2/M5, Xiao C3, LilyGo T-LoRa V2.1) ## Duty-cycle region gating `dc.gate.*`. Standaard **uit**. Werpt inter-regio-floodverkeer af wanneer de repeater -het grootste deel van zijn duty-cycle-budget heeft verbruikt. Transient: wordt nooit naar +een groot deel van de tijd zendt. Transient: wordt nooit naar de regioconfiguratie weggeschreven. Draait vóór de packet filter, dus deze drops verschijnen niet in `filter stats`. - `set dc.gate ` / `get dc.gate` (`1`/`0` werkt ook) -- `set dc.gate.thresh <1-100>` / `get dc.gate.thresh` (standaard 70) +- `set dc.gate.thresh <1-100>` / `get dc.gate.thresh` (standaard 35) - `set dc.gate.hyst <0-50>` / `get dc.gate.hyst` (standaard 10) -- `get dc.gate.status` (bijv. `duty 74%, gate level 2/4`) - -De drempel, de hysterese en `duty` zijn een percentage van het **duty-cycle-budget** -(`set dutycycle`), niet van de kloktijd. Met `dutycycle 10` mag de repeater 360 seconden -per uur zenden; `dc.gate.thresh 70` start de gating zodra 252 van die seconden zijn -verbruikt. De drempel hoeft dus niet onder de duty-cycle-limiet te liggen. +- `get dc.gate.status` (bijv. `duty 41%, gate level 2/4`) + +De drempel, de hysterese en `duty` zijn de **echte TX-duty-cycle**: het deel van de +kloktijd dat de repeater de afgelopen 60 seconden zond. Dat is iets anders dan de +duty-cycle-limiet (`set dutycycle`): die geldt per uur, uit een zendtijdreserve die de +repeater in pieken mag opmaken. Met `dutycycle 10` (360 seconden per uur) blijft normaal +verkeer ruim onder 35%; een repeater die 35% van de tijd zendt, heeft de reserve in +ongeveer 24 minuten op, en de gating grijpt eerder in. Op een 1%-subband is `10`-`15` +een betere drempel. + +Eerdere DMC-builds maten een percentage van het duty-cycle-budget (standaard 70). Die +waarde wordt na de update niet overgenomen; de drempel begint dan op 35. ## Duty cycle diff --git a/docs/DMC_DEVSYNC.md b/docs/DMC_DEVSYNC.md index ee5a0451a48..453f1582803 100644 --- a/docs/DMC_DEVSYNC.md +++ b/docs/DMC_DEVSYNC.md @@ -109,7 +109,7 @@ Firmware CLI/pref changes need matching updates in the sibling `Dutch-Meshcore-T - **BUG to fix (user-facing, nl/en/de):** `src/components/config/panelHelpContent.ts` `regionGating` intros/fields describe `dc.gate` as **neighbour-count** based ("aantal buren" / "number of neighbours"). The firmware is **TX-duty-cycle** based: `dc.gate.thresh` = TX duty-cycle % above - which outer regions get gated (default 70), `dc.gate.hyst` = recovery margin % (default 10); it + which outer regions get gated (true TX duty over 60 s, default 35), `dc.gate.hyst` = recovery margin % (default 10); it sheds airtime, not neighbours. The CLI `descriptions.{nl,de}.ts` already describe it correctly as duty %, so the help panel contradicts them. Rewrite the three-language help to the duty-cycle model. - Register `v1.17.1.01` in `src/lib/cli/firmwareRegistry.ts`; add an en/nl changelog entry diff --git a/docs/cli_commands.md b/docs/cli_commands.md index eb5cbb799b0..2c80301ab87 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -775,15 +775,15 @@ reports which of the two is active. #### Duty-cycle region gating -Autonomously sheds inter-region flood traffic when this repeater has used up most -of its duty-cycle budget, protecting the local cluster during high-traffic or disruption +Autonomously sheds inter-region flood traffic when this repeater is transmitting a +large share of the time, protecting the local cluster during high-traffic or disruption events — no admin access needed once configured. Opt-in and **off by default**. It reuses the existing [Region Management](#region-management-v110) hierarchy. -When budget use rises above the threshold, regions are gated from the +When the TX duty cycle rises above the threshold, regions are gated from the outermost layer inward — the wildcard `*` first, then the broadest named regions — always keeping the innermost cluster and the operator's home region open. As -budget use recovers below `threshold − hysteresis`, regions re-open +the duty cycle falls below `threshold − hysteresis`, regions re-open inside-out, with a small random per-step delay so nearby repeaters don't all recover in lockstep. The gate is transient: it is never written to the region config, so a `region save` or a reboot mid-event can never make a deny permanent. @@ -791,40 +791,51 @@ config, so a `region save` or a reboot mid-event can never make a deny permanent **Usage:** - `set dc.gate ` — enable or disable the feature (`1`/`0` work too) - `get dc.gate` — show whether it is on or off -- `set dc.gate.thresh <1-100>` — % of the duty-cycle budget used above which gating starts +- `set dc.gate.thresh <1-100>` — TX duty cycle (% of wall-clock time spent transmitting) above which gating starts - `get dc.gate.thresh` - `set dc.gate.hyst <0-50>` — recovery margin in percentage points: regions re-open below `(threshold − hysteresis)` - `get dc.gate.hyst` -- `get dc.gate.status` — live % of the duty-cycle budget used, and current gate level (`level/max`) +- `get dc.gate.status` — live TX duty cycle over the last 60 seconds, and current gate level (`level/max`) -**Defaults:** off; threshold `70`; hysteresis `10` (so recovery begins below 60%). +**Defaults:** off; threshold `35`; hysteresis `10` (so recovery begins below 25%). **What the percentage measures:** -`dc.gate.thresh`, `dc.gate.hyst` and the `duty` in `get dc.gate.status` are a -percentage of this repeater's **duty-cycle budget** (set with -[`set dutycycle`](#view-or-change-the-duty-cycle-limit)), **not** a percentage of -wall-clock time. The budget is the airtime the duty-cycle limit allows per -hour, and it refills continuously. `duty 0%` means the full budget is -available; `duty 100%` means it is used up and the repeater has to hold back -transmissions until it refills. - -For example, with `set dutycycle 10` the repeater may transmit for 360 seconds -per hour. With the default threshold of `70`, gating starts once 70% of that -budget is used (252 seconds, so less than 108 seconds left), and regions -start re-opening once usage drops below 60% (216 seconds). The threshold -therefore does **not** have to be set below the duty-cycle limit: `70` or `80` -is sensible with a 1%, 10% or any other limit. - -With no duty-cycle limit (`set dutycycle 100`, or `auto` outside the -863-870 MHz table) the budget is the whole hour, so the percentage then does -equal the share of wall-clock time spent transmitting. +`dc.gate.thresh`, `dc.gate.hyst` and the `duty` in `get dc.gate.status` are the +repeater's **true TX duty cycle**: the share of wall-clock time it spent +transmitting over the last 60 seconds (six 10-second slots). `duty 4%` means it +transmitted for about 2.4 seconds in the last minute. The gate checks the duty +cycle every 10 seconds and moves at most one region layer per check. + +This is not the same as the duty-cycle limit +([`set dutycycle`](#view-or-change-the-duty-cycle-limit)). The limit applies per +hour, from an airtime reserve that refills continuously: with `dutycycle 10` the +repeater may transmit for 360 seconds per hour, and it may spend that reserve in +bursts. A burst can push the 60-second duty cycle far above the limit; once the +reserve runs low, TX pacing holds the repeater to the limit on its own. + +The default of `35` sits between the two. It is well above the 10% a repeater on +the EU 869.4-869.65 MHz sub-band can sustain, so normal traffic does not trip it. +A repeater transmitting 35% of the time, however, empties its 360-second reserve +in about 24 minutes, and gating sheds inter-region floods before that happens. +Suggested thresholds: + +- 10% sub-band (`dutycycle auto` around 869.5 MHz): the default `35`. +- 1% sub-band (36 seconds per hour): `10`-`15`, because a single minute at 35% + already uses more than half of the reserve. +- No duty-cycle limit (`dutycycle 100`, or `auto` outside 863-870 MHz): `35`-`50`, + to protect channel capacity rather than a legal budget. + +> **Upgrading:** earlier DMC builds measured a percentage of the duty-cycle budget +> used, with default `70`. That value is stored under a different key and is not +> carried over: after upgrading, the threshold starts at the new default `35`. +> Re-set it if you had tuned it. **Examples:** - `set dc.gate on` — turn gating on -- `set dc.gate.thresh 80` — only start shedding once 80% of the duty-cycle budget is used -- `set dc.gate.hyst 15` — re-open regions once usage drops below 65% of the budget -- `get dc.gate.status` — e.g. `> duty 74%, gate level 2/4` +- `set dc.gate.thresh 50` — only start shedding once the repeater transmits half the time +- `set dc.gate.hyst 15` — with threshold `35`, re-open regions once the duty cycle drops below 20% +- `get dc.gate.status` — e.g. `> duty 41%, gate level 2/4` **Notes:** - The innermost (deepest) region layer and the configured home region are never gated. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 37c07dadd2f..78c6162a553 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -924,8 +924,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc // duty-cycle region gating defaults (feature is opt-in, off by default) _prefs.dc_gate_enabled = 0; - _prefs.dc_gate_threshold = 70; // start gating above 70% TX duty cycle - _prefs.dc_gate_hysteresis = 10; // recover below 60% + _prefs.dc_gate_threshold = 35; // start gating above 35% true TX duty cycle + _prefs.dc_gate_hysteresis = 10; // recover below 25% // bridge defaults _prefs.bridge_enabled = 1; // enabled diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index c26c12d1c4a..8c1afc32070 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -26,6 +26,7 @@ void Dispatcher::begin() { float duty_cycle = 1.0f / (1.0f + getAirtimeBudgetFactor()); tx_budget_ms = (unsigned long)(duty_cycle_window_ms * duty_cycle); last_budget_update = _ms->getMillis(); + _tx_duty.reset(_ms->getMillis()); _radio->begin(); prev_isrecv_mode = _radio->isInRecvMode(); @@ -53,14 +54,12 @@ void Dispatcher::updateTxBudget() { } uint8_t Dispatcher::getTxDutyCyclePercent() { - updateTxBudget(); // refill first, so the reading is current even when the mesh is idle - float duty_cycle = 1.0f / (1.0f + getAirtimeBudgetFactor()); - unsigned long max_budget = (unsigned long)(getDutyCycleWindowMs() * duty_cycle); - if (max_budget == 0) return 0; - float used = 1.0f - (float)tx_budget_ms / (float)max_budget; - if (used <= 0.0f) return 0; - if (used >= 1.0f) return 100; - return (uint8_t)(used * 100.0f + 0.5f); + // True TX duty cycle: fraction of wall-clock time spent transmitting over a + // rolling ~60 s window (see TxDutyWindow). This is intentionally NOT derived + // from tx_budget_ms: that bucket is the airtime/compliance limiter (it paces + // TX and is left untouched), and its "budget headroom" reads ~0 for any + // normal repeater. Passing "now" lets an idle node's duty decay to 0. + return _tx_duty.percent(_ms->getMillis()); } int Dispatcher::calcRxDelay(float score, uint32_t air_time) const { @@ -98,6 +97,7 @@ void Dispatcher::loop() { if (_radio->isSendComplete()) { long t = _ms->getMillis() - outbound_start; total_air_time += t; + if (t > 0) _tx_duty.addAirtime(_ms->getMillis(), (unsigned long)t); // feed the true-duty-cycle window //Serial.print(" airtime="); Serial.println(t); updateTxBudget(); diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 03bc8eac56e..b64146e0078 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -5,6 +5,7 @@ #include #include #include +#include "helpers/TxDutyWindow.h" namespace mesh { @@ -128,6 +129,7 @@ class Dispatcher { unsigned long tx_budget_ms; unsigned long last_budget_update; unsigned long duty_cycle_window_ms; + TxDutyWindow _tx_duty; // rolling-window true TX duty cycle (metric + gate) void processRecvPacket(Packet* pkt); void updateTxBudget(); @@ -152,6 +154,7 @@ class Dispatcher { tx_budget_ms = 0; last_budget_update = 0; duty_cycle_window_ms = 3600000; + _tx_duty.reset(ms.getMillis()); } virtual DispatcherAction onRecvPacket(Packet* pkt) = 0; @@ -183,7 +186,7 @@ class Dispatcher { unsigned long getTotalAirTime() const { return total_air_time; } unsigned long getReceiveAirTime() const {return rx_air_time; } unsigned long getRemainingTxBudget() const { return tx_budget_ms; } - uint8_t getTxDutyCyclePercent(); // live TX airtime usage, 0..100 (% of permitted budget) + uint8_t getTxDutyCyclePercent(); // true TX duty cycle, 0..100 (% of wall-clock spent transmitting, ~60s window) uint32_t getNumSentFlood() const { return n_sent_flood; } uint32_t getNumSentDirect() const { return n_sent_direct; } uint32_t getNumRecvFlood() const { return n_recv_flood; } diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 91c7513da82..b6bdf56d799 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -137,9 +137,11 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Legacy _prefs->radio_fem_txgain = constrain(_prefs->radio_fem_txgain, 0, 1); // boolean _prefs->cad_enabled = constrain(_prefs->cad_enabled, 0, 1); // boolean - // duty-cycle region gating + // duty-cycle region gating. dc_gate_threshold is a true TX duty cycle + // percent (share of wall-clock time spent transmitting; see + // Dispatcher::getTxDutyCyclePercent), NOT the old "% of airtime budget". _prefs->dc_gate_enabled = constrain(_prefs->dc_gate_enabled, 0, 1); // boolean - _prefs->dc_gate_threshold = constrain(_prefs->dc_gate_threshold, 1, 100); // percent + _prefs->dc_gate_threshold = constrain(_prefs->dc_gate_threshold, 1, 100); // true TX duty cycle % _prefs->dc_gate_hysteresis = constrain(_prefs->dc_gate_hysteresis, 0, 50);// percent margin file.close(); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index f8925559c29..b38623bd97e 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -72,7 +72,7 @@ class NodePrefs : public ConfigSerializer { uint8_t loop_detect = 0; uint8_t cad_enabled = 0; // hardware Channel Activity Detection before TX (boolean) uint8_t dc_gate_enabled = 0; // duty-cycle region gating: 0 = off (opt-in) - uint8_t dc_gate_threshold = 70; // TX duty-cycle % above which outer regions start being gated + uint8_t dc_gate_threshold = 35; // true TX duty % (60 s window) above which outer regions start being gated uint8_t dc_gate_hysteresis = 10;// recover margin in %: re-enable regions below (threshold - hysteresis) uint8_t dutycycle_auto = 1; // derive the duty cycle limit from freq (boolean) uint8_t extra_sf[4]; @@ -194,7 +194,11 @@ class NodePrefs : public ConfigSerializer { def("f_max_adv", _parent->flood_max_advert); def("loop", _parent->loop_detect); def("dc_gate", _parent->dc_gate_enabled); // duty-cycle region gating: 0 = off (opt-in) - def("dc_gate_thr", _parent->dc_gate_threshold); // TX duty-cycle % to start gating outer regions + // "dc_gate_duty", not the old "dc_gate_thr": that key held a % of the + // duty-cycle budget. Values stored under it are left behind on purpose, + // so upgraded nodes start from the true-duty default instead of a + // budget % that would rarely trip. + def("dc_gate_duty", _parent->dc_gate_threshold); // true TX duty % to start gating outer regions def("dc_gate_hyst", _parent->dc_gate_hysteresis);// recover margin %: re-enable below (thr - hyst) } public: diff --git a/src/helpers/TxDutyWindow.h b/src/helpers/TxDutyWindow.h new file mode 100644 index 00000000000..d759904ab3c --- /dev/null +++ b/src/helpers/TxDutyWindow.h @@ -0,0 +1,74 @@ +#pragma once + +#include + +/** + * \brief Rolling-window TX-airtime accumulator that reports the true TX duty + * cycle (transmit airtime / wall-clock time) as a 0..100 percent. + * + * The window is SLOTS fixed-width time bins (SLOTS * SLOT_MS of wall-clock in + * total). Transmit airtime is credited to the current bin via addAirtime(); + * bins age out as wall-clock advances, so an idle node decays back to 0. All + * times are milliseconds and the caller injects "now" (the mesh clock), which + * keeps this type pure and unit-testable off-device. + * + * This is the metric the region gate and the observer chart read: a repeater + * transmitting 4% of the time reads 4%. It is intentionally separate from the + * Dispatcher's tx_budget_ms airtime limiter, which enforces TX pacing and is + * left untouched. + */ +class TxDutyWindow { +public: + static const uint8_t SLOTS = 6; + static const unsigned long SLOT_MS = 10000; // 10 s per slot + static const unsigned long WINDOW_MS = SLOTS * SLOT_MS; // 60 s window + + TxDutyWindow() { reset(0); } + + // Start (or restart) the window empty, anchored at `now`. + void reset(unsigned long now) { + for (uint8_t i = 0; i < SLOTS; i++) _slot[i] = 0; + _cur = 0; + _slot_start = now; + } + + // Credit `airtime_ms` of transmit time that finished at `now`. + void addAirtime(unsigned long now, unsigned long airtime_ms) { + advance(now); + _slot[_cur] += airtime_ms; + } + + // True TX duty cycle over the last WINDOW_MS, as a rounded 0..100 percent. + uint8_t percent(unsigned long now) { + advance(now); + unsigned long sum = 0; + for (uint8_t i = 0; i < SLOTS; i++) sum += _slot[i]; + if (sum == 0) return 0; + if (sum >= WINDOW_MS) return 100; // cannot exceed 100% + return (uint8_t)((sum * 100UL + WINDOW_MS / 2) / WINDOW_MS); + } + +private: + // Roll the window forward to `now`, clearing bins that have aged out. Uses + // unsigned subtraction so it is correct across the millis() wrap. + void advance(unsigned long now) { + unsigned long elapsed = now - _slot_start; + if (elapsed < SLOT_MS) return; // still in current bin + unsigned long steps = elapsed / SLOT_MS; + if (steps >= SLOTS) { // idle >= whole window + for (uint8_t i = 0; i < SLOTS; i++) _slot[i] = 0; + _cur = 0; + _slot_start = now; + return; + } + for (unsigned long s = 0; s < steps; s++) { + _cur = (uint8_t)((_cur + 1) % SLOTS); + _slot[_cur] = 0; + } + _slot_start += steps * SLOT_MS; + } + + unsigned long _slot[SLOTS]; // TX airtime (ms) credited to each bin + unsigned long _slot_start; // wall-clock start of the current bin + uint8_t _cur; // index of the current bin +}; diff --git a/test/test_region_gating/test_tx_duty_window.cpp b/test/test_region_gating/test_tx_duty_window.cpp new file mode 100644 index 00000000000..3a9a200d3ed --- /dev/null +++ b/test/test_region_gating/test_tx_duty_window.cpp @@ -0,0 +1,71 @@ +#include +#include "helpers/TxDutyWindow.h" + +// Unit tests for TxDutyWindow: the rolling-window TX-airtime accumulator that +// yields the true TX duty cycle (airtime / wall-clock) as a 0..100 percent. +// The clock is injected (every call takes "now"), so these are pure and need +// no radio/mesh. Window is TxDutyWindow::WINDOW_MS (60 s of 10 s slots). + +static const unsigned long W = TxDutyWindow::WINDOW_MS; // 60000 + +// An untouched / idle window reads 0, and stays 0 as time passes with no TX. +TEST(TxDutyWindow, IdleReadsZero) { + TxDutyWindow d; + d.reset(1000); + EXPECT_EQ(d.percent(1000), 0); + EXPECT_EQ(d.percent(1000 + W), 0); + EXPECT_EQ(d.percent(1000 + 10 * W), 0); +} + +// Airtime equal to X% of the window reads back as X%. +TEST(TxDutyWindow, SteadyAirtimeGivesTruePercent) { + TxDutyWindow d; + d.reset(0); + d.addAirtime(0, W / 10); // 6000 ms of TX = 10% of the 60 s window + EXPECT_EQ(d.percent(0), 10); + TxDutyWindow d2; + d2.reset(0); + d2.addAirtime(0, W / 4); // 25% + EXPECT_EQ(d2.percent(0), 25); +} + +// A duty cycle cannot exceed 100%, even if bogus/overlapping airtime is fed in. +TEST(TxDutyWindow, ClampsAt100) { + TxDutyWindow d; + d.reset(0); + d.addAirtime(0, W); // exactly the whole window + EXPECT_EQ(d.percent(0), 100); + d.addAirtime(0, W); // more than the window + EXPECT_EQ(d.percent(0), 100); +} + +// Airtime credited in different slots accumulates within the window. +TEST(TxDutyWindow, MultipleSlotsAccumulate) { + TxDutyWindow d; + d.reset(0); + d.addAirtime(0, 3000); // slot 0 + d.addAirtime(TxDutyWindow::SLOT_MS, 3000); // slot 1, 10 s later + EXPECT_EQ(d.percent(TxDutyWindow::SLOT_MS), 10); // 6000 / 60000 = 10% +} + +// Old airtime ages out: a burst is still counted just inside the window and +// gone once it is a full window old. +TEST(TxDutyWindow, SlidingExpiry) { + TxDutyWindow d; + d.reset(0); + d.addAirtime(0, W / 10); // 10% burst at t = 0 + EXPECT_EQ(d.percent(0), 10); + EXPECT_EQ(d.percent(W - 5000), 10); // still within the 60 s window + EXPECT_EQ(d.percent(W), 0); // now a full window old: aged out +} + +// After a long idle gap the window is fully cleared (no stale airtime lingers). +TEST(TxDutyWindow, LongIdleClearsWindow) { + TxDutyWindow d; + d.reset(0); + d.addAirtime(0, W / 2); // 50% burst + EXPECT_EQ(d.percent(0), 50); + EXPECT_EQ(d.percent(100 * W), 0); // idle for 100 windows: back to 0 + d.addAirtime(100 * W, W / 5); // and the window still works afterwards + EXPECT_EQ(d.percent(100 * W), 20); +}