Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/run-unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 14 additions & 8 deletions dmc-docs/custom-cli.de.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <on|off>` / `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

Expand Down
24 changes: 15 additions & 9 deletions dmc-docs/custom-cli.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <on|off>` / `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

Expand Down
22 changes: 14 additions & 8 deletions dmc-docs/custom-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <on|off>` / `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

Expand Down
2 changes: 1 addition & 1 deletion docs/DMC_DEVSYNC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 39 additions & 28 deletions docs/cli_commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -775,56 +775,67 @@ 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.

**Usage:**
- `set dc.gate <on|off>` — 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.
Expand Down
4 changes: 2 additions & 2 deletions examples/simple_repeater/MyMesh.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions src/Dispatcher.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 4 additions & 1 deletion src/Dispatcher.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include <Packet.h>
#include <Utils.h>
#include <string.h>
#include "helpers/TxDutyWindow.h"

namespace mesh {

Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -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; }
Expand Down
6 changes: 4 additions & 2 deletions src/helpers/CommonCLI.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 6 additions & 2 deletions src/helpers/CommonCLI.h
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading