Skip to content

Hierarchical region gating via TX duty cycle (simple_repeater)#2960

Open
khagele wants to merge 1 commit into
meshcore-dev:mainfrom
khagele:feature/region-duty-cycle-gating
Open

Hierarchical region gating via TX duty cycle (simple_repeater)#2960
khagele wants to merge 1 commit into
meshcore-dev:mainfrom
khagele:feature/region-duty-cycle-gating

Conversation

@khagele

@khagele khagele commented Jul 15, 2026

Copy link
Copy Markdown

Implements the proposal in #2747: autonomous, per-repeater gating that sheds inter-region traffic when a repeater's own TX duty cycle is high, so the local cluster keeps working during high-traffic/disruption events without needing admin access.

This is a reference implementation of the proposal, opened to move the discussion to concrete code. Credit to @fizzyfuzzle, whose POC in the issue thread this tracks and generalizes, and to the NL/CZ operators (@LennertVA, @DreitTheDragon, @onnozweers, @titusn) whose feedback shaped the design.

How it works

Reuses the existing RegionMap parent hierarchy and Dispatcher airtime budget. When TX duty cycle exceeds a threshold, regions are gated from the outermost layer inward — wildcard * first, then the broadest named regions — always protecting the innermost cluster. Recovery re-enables regions inside-out once duty cycle falls below threshold − hysteresis, with per-step random jitter to desync recovery across repeaters. Multiple regions on the same hierarchy level gate together; their leaves stay protected.

Design / safety

  • Transient overlay: a new non-persisted rt_flags field on RegionEntry is OR'd with config flags in the forward path, so a region save or reboot mid-event can never make a deny permanent.
  • Self-healing state: the gate level is re-asserted every check interval, so region edits or a bulk region load commit (which rebuild entries with cleared rt_flags) cannot desync the gate; disabling the feature while gated lifts the gate immediately.
  • Idle-safe recovery: Dispatcher::getTxDutyCyclePercent() refreshes the airtime budget before reading, so recovery still fires when traffic (and thus TX) stops.

Config (opt-in, off by default; persisted with upgrade-safe append)

set dc.gate <0|1> · set dc.gate.thresh <1-100> (default 70) · set dc.gate.hyst <0-50> (default 10) · get dc.gate[.thresh|.hyst] · get dc.gate.status (live duty % + gate level).

Gate state is intentionally not added to the region tree dump for now — that format is parsed and round-tripped by deployed apps, so surfacing per-region gate state in "Manage Regions" is left as an open question. Live state is available via get dc.gate.status.

Verification

  • Full Heltec_E290_repeater firmware builds & links.
  • Native gtest suite (test/test_region_gating/) covering wildcard-first ordering, leaf protection, inside-out recovery, config/runtime separation, same-level regions, and edge cases; native mocks (Arduino.h/Stream.h/SHA256.h) extended so RegionMap builds off-device.

Open questions / out of scope

  • Duty-cycle metric uses the existing fixed 1-hour window → strong hysteresis but slow recovery; the window could be made configurable later (override getDutyCycleWindowMs(), mirroring getAirtimeBudgetFactor()).
  • On an unbalanced tree, a whole-depth gate can drop a shallow branch's leaf before the deepest branch's; a per-branch "never gate a childless leaf" refinement is a possible follow-up.
  • Reserved admin airtime (@titusn) and a flat priority-number model (@onnozweers) are noted as separate follow-ups.
  • Only wired into simple_repeater; simple_room_server would be a trivial follow-up.

🤖 Generated with Claude Code

Autonomously shed inter-region traffic when a repeater's own TX duty
cycle is high, protecting the local cluster during high-traffic events
(issue meshcore-dev#2747). Opt-in, off by default.

When TX duty cycle exceeds a configurable threshold, regions are gated
from the outermost layer inward: wildcard '*' first, then the broadest
named regions, always keeping the innermost cluster. Recovery re-enables
regions inside-out once duty cycle drops below (threshold - hysteresis),
with per-step random jitter to desync recovery across repeaters.

Design notes:
- Gating is transient: a new non-persisted rt_flags overlay on
  RegionEntry is OR'd with config flags in the forward path, so a
  'region save' or reboot mid-event can never make a deny permanent.
- The gate level is re-asserted every check interval, so region edits
  or a bulk 'region load' commit (which rebuild entries with cleared
  rt_flags) cannot desync the gate state; disabling the feature while
  gated lifts the gate immediately.
- Dispatcher::getTxDutyCyclePercent() refreshes the airtime budget
  before reading, so recovery still fires when traffic (and TX) stops.

Config (persisted, appended for upgrade compat):
  set dc.gate <0|1>, set dc.gate.thresh <1-100>, set dc.gate.hyst <0-50>
  get dc.gate[.thresh|.hyst], get dc.gate.status (live duty% + level)

Adds a native gtest suite for the gating logic (test/test_region_gating)
and extends the native test mocks (Arduino/Stream/SHA256) so RegionMap
builds off-device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

rm.applyDutyGate(0);
EXPECT_FALSE(gated(rm, "*"));
EXPECT_FALSE(gated(rm, "eu"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/nitpick: should this not expect all four regions to not be gated and would splitting this test up into multiple tests make it more readable, especially for later debugging if the test ever fails? If this is according to the current coding guidelines, no problem of course. However a basic rule is: a test should contain one expect. Same can be said about the tests below.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants