Skip to content

Default transmit duty cycle to 10% instead of 50% - #3351

Closed
efiten wants to merge 1 commit into
meshcore-dev:devfrom
efiten:fix/eu-compliant-default-duty-cycle
Closed

Default transmit duty cycle to 10% instead of 50%#3351
efiten wants to merge 1 commit into
meshcore-dev:devfrom
efiten:fix/eu-compliant-default-duty-cycle

Conversation

@efiten

@efiten efiten commented Sep 3, 2026

Copy link
Copy Markdown

Refs #2047.

Problem

The shipped default is airtime_factor = 1.0, which get dutycycle reports as 50.0%.

The default frequency is -D LORA_FREQ=869.618 (platformio.ini:29). That falls in the 869.4 to 869.65 MHz sub-band, which ETSI EN 300 220-2 V3.2.1 caps at 10%. Per-sub-band limits for EU868 are 0.1% (863-865), 1% (865-868), 1% (868-868.6), 0.1% (868.7-869.2), 10% (869.4-869.65) and 1% (869.7-870); see https://www.actility.com/understanding-duty-cycle-lorawan/ for the table and the standard reference. The same 10% figure applies in the UK.

So the default configuration allows five times the duty cycle that applies to its own default frequency. Every EU and UK operator has to change this by hand on each node, and the value is not discoverable unless you already know that af means duty cycle.

Change

  • src/Dispatcher.h: new MAX_DUTY_CYCLE (percent, default 10) and DEFAULT_AIRTIME_FACTOR, derived with the same conversion set dutycycle already uses (CommonCLI.cpp:455).
  • The five node types that hardcoded _prefs.airtime_factor = 1.0 now use DEFAULT_AIRTIME_FACTOR: companion_radio, simple_repeater, simple_room_server, simple_secure_chat, simple_sensor.
  • Dispatcher::getAirtimeBudgetFactor() returned a hardcoded 1.0 as the base class fallback; it now uses the same constant.
  • docs/cli_commands.md documented 50% as the default.

Builds for regions without a duty cycle limit can keep the old behaviour with -D MAX_DUTY_CYCLE=100.

What this does not change

Only the default for a node with no stored preferences. The persisted field is still airtime_factor (key af in the config serializer, offset 0 in the legacy prefs file), and set dutycycle and set af behave exactly as before. Existing nodes keep whatever they have configured.

Scope left out deliberately

Two things I noticed while reading the duty cycle code and did not touch here, to keep this to one change:

  1. The token bucket in Dispatcher::updateTxBudget() starts full and its ceiling is a full window's allowance. In the worst case a node can spend the full bucket plus a window of refill inside one observation window, so roughly twice the nominal duty cycle in that window. ETSI measures over a one hour observation period, so this can overshoot even when configured correctly.
  2. The constrain(airtime_factor, 0, 9.0f) sanitisation only exists in loadPrefsInt(), the legacy /com_prefs loader. The /prefs.json path via loadSerial() does not clamp, so set dutycycle 1 (af 99) persists there but is reset to 10% for a node migrating from the legacy format.

Happy to open separate issues for either if useful.

Testing

Not covered by a unit test. The native test environment (platformio.ini, [env:native]) does not compile Dispatcher.cpp and uses a mock Mesh.h, so testing this would mean adding a mock radio to that environment, which is a larger change than the default itself. Verified instead that DEFAULT_AIRTIME_FACTOR resolves in all five translation units: each includes <Mesh.h>, which includes <Dispatcher.h> at src/Mesh.h:3. The CI build matrix covers the compile.

The shipped default was airtime factor 1.0, which CommonCLI reports as a
50% duty cycle. The default frequency is 869.618 MHz (platformio.ini),
which sits in the 869.4 to 869.65 MHz sub-band. ETSI EN 300 220-2 V3.2.1
caps that sub-band at 10%, so the out-of-the-box configuration exceeds
the limit that applies to its own default frequency.

Introduce MAX_DUTY_CYCLE (percent, default 10) in Dispatcher.h and derive
DEFAULT_AIRTIME_FACTOR from it using the same conversion as
'set dutycycle'. Apply it to the five node types that hardcoded 1.0 and
to the Dispatcher base class fallback. Regions without a duty cycle limit
can restore the old behaviour with -D MAX_DUTY_CYCLE=100.

Only the default changes. The stored preference is still airtime_factor,
and 'set dutycycle' and 'set af' keep working as before.

Refs meshcore-dev#2047

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EaVu6vC5LziX1zYZeiu49X
@OverkillFPV

Copy link
Copy Markdown
Contributor

It should probably be linked with the preset as quite a lot of regions do not have duty cycle limits, and a 10% default only applies to regions that do

@efiten

efiten commented Sep 5, 2026

Copy link
Copy Markdown
Author

You are right, and #3351 cannot be fixed in place. There is no region concept in the firmware: LORA_FREQ is set once in platformio.ini:29 and no variants/*/platformio.ini overrides it, so the MAX_DUTY_CYCLE build flag this PR added has no build to attach itself to. In practice it would have moved US and ANZ nodes from 50% to 10% with no way back short of a custom build.

Replaced by #3361, which derives the limit from the sub-band of the configured frequency instead. Outside 863 to 870 MHz it returns no limit, so the channel plans without a duty cycle regulation keep the behaviour they have now, and it re-derives on set freq and set radio so it follows the preset the client applies.

Closing this one in favour of that.

@efiten efiten closed this Sep 5, 2026
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