Skip to content

Derive the duty cycle limit from the configured frequency - #3361

Open
efiten wants to merge 2 commits into
meshcore-dev:devfrom
efiten:fix/dutycycle-from-subband
Open

Derive the duty cycle limit from the configured frequency#3361
efiten wants to merge 2 commits into
meshcore-dev:devfrom
efiten:fix/dutycycle-from-subband

Conversation

@efiten

@efiten efiten commented Sep 5, 2026

Copy link
Copy Markdown

Replaces #3351, which set a single 10% default for every build. As noted there, many regions have no duty cycle limit at all, so one fixed default is wrong outside the EU.

Problem

The shipped default is airtime_factor = 1.0, which get dutycycle reports as 50%. The default frequency is 869.618 MHz (platformio.ini:29), and ETSI EN 300 220-2 caps the 869.4 to 869.65 sub-band at 10%. The default is five times higher than what is allowed on its own default frequency.

A build constant cannot express that limit. It differs per sub-band: 868.5 allows 1%, 869.0 allows 0.1%, and on 910 or 915 MHz there is no ETSI limit. The repo also builds one binary per board, with no LORA_FREQ override in any variants/*/platformio.ini, so there is no region-specific build to attach a default to. The region is picked at runtime through the client preset.

Change

New src/helpers/DutyCycleLimits.h and .cpp. getMaxDutyCyclePercent(freq) looks up the sub-band, dutyCycleToAirtimeFactor(percent) is the conversion set dutycycle already used, and getEffectiveAirtimeFactor() combines both with the auto flag.

Sub-band (MHz) Limit
863.0 to 865.0 0.1%
865.0 to 868.0 1%
868.0 to 868.6 1%
868.7 to 869.2 0.1%
869.4 to 869.65 10%
869.7 to 870.0 1%
the gaps between them 0.1%

The table covers 863 to 870 MHz only. Every other frequency returns DUTY_CYCLE_UNLIMITED, so the US and ANZ channel plans keep the behaviour they have now. A frequency exactly on a boundary takes the lower of the two sub-bands, a gap inside the band takes the tightest limit of the band.

This covers the country presets within the EU as well. A preset that differs only in SF, such as NL on SF7, sits on the same sub-band as the general EU preset and gets the same limit. Only the frequency touches the regulation.

New pref dutycycle_auto, default 1, serialised as dc_auto, added to both NodePrefs classes and exposed through two new CommonRadioPrefs virtuals. While the flag is set, the five getAirtimeBudgetFactor() overrides return the derived factor instead of the stored one. Dispatcher::updateTxBudget() calls that on every pass (src/Dispatcher.cpp:42), so the limit follows set freq and set radio without a reboot and without any change to Dispatcher.

set dutycycle <n>, set af and the companion CMD_SET_TUNING_PARAMS clear the flag, so an explicit value stays in force until set dutycycle auto restores it. get dutycycle, get af and CMD_GET_TUNING_PARAMS report the factor that is actually in force.

Behaviour change

Existing nodes migrate to auto. Neither the legacy /com_prefs layout (src/helpers/CommonCLI.cpp:49) nor an existing prefs.json carries dc_auto, so the struct default applies. A node on 869.618 that never touched its duty cycle drops from 50% to 10%.

On the 868.7 to 869.2 sub-band auto resolves to 0.1%, stricter than the 1% floor set dutycycle accepts. That is the ETSI limit for that sub-band, but it makes those nodes nearly silent. set dutycycle <n> overrides it.

simple_secure_chat writes its prefs struct as raw bytes with no version field. dutycycle_auto is appended after the existing unused[3] instead of taking one of those bytes, so the struct grows from 64 to 72 bytes and a shorter legacy file leaves the new field at its constructor value of 1. Those nodes migrate to auto like the rest.

Out of scope

applyTempRadioParams (src/helpers/CommonCLI.cpp:262) sets a temporary frequency without touching _prefs.freq, so during tempradio the limit stays that of the stored frequency. That is a separate change.

The existing constrain(airtime_factor, 0, 9.0f) in loadPrefsInt() and the upper bound of 100 in set dutycycle are left alone.

Testing

pio test -e native, all 51 cases including the 6 new ones and no regression in the existing suites:

native  test_utf8_helpers       PASSED
native  test_config_serializer  PASSED
native  test_mesh_tables        PASSED
native  test_utils              PASSED
native  test_duty_cycle_limits  PASSED
native  test_routing_policy     PASSED
51 test cases: 51 succeeded

Compile coverage for all five node types on both platforms:

Heltec_v3_repeater             SUCCESS   RAK_4631_repeater             SUCCESS
Heltec_v3_room_server          SUCCESS   RAK_4631_room_server          SUCCESS
Heltec_v3_terminal_chat        SUCCESS   RAK_4631_terminal_chat        SUCCESS
Heltec_v3_companion_radio_ble  SUCCESS   RAK_4631_companion_radio_ble  SUCCESS
Heltec_v3_sensor               SUCCESS   RAK_4631_sensor               SUCCESS

The simple_secure_chat struct growth was checked separately on aarch64: sizeof goes from 64 to 72 with the new field at offset 64, and copying a 64 byte legacy image over an initialised struct leaves dutycycle_auto at 1. That check was not run on the 32 bit ARM and Xtensa targets themselves.

Not tested on hardware.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EbLWXAAQGKA8AGgg5WnCcj

efiten and others added 2 commits September 5, 2026 15:35
The shipped default of airtime_factor 1.0 reports as 50%, while ETSI EN
300 220-2 caps the 869.4 to 869.65 sub-band at 10%, and that sub-band is
where the default LORA_FREQ of 869.618 lives. A build constant cannot
express the limit, because it differs per sub-band and does not exist at
all outside 863 to 870 MHz.

New DutyCycleLimits looks the limit up from a frequency and converts it
to the airtime budget factor Dispatcher works with. New dutycycle_auto
pref, default on, makes the five getAirtimeBudgetFactor() overrides
return that derived factor. Dispatcher::updateTxBudget() re-reads it on
every pass, so the limit follows set freq and set radio without a reboot.

set dutycycle <n>, set af and CMD_SET_TUNING_PARAMS clear the flag, so an
explicit value stays in force until set dutycycle auto restores it. The
matching getters report the factor that is actually applied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbLWXAAQGKA8AGgg5WnCcj
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbLWXAAQGKA8AGgg5WnCcj
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.

1 participant