diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 738d366c94..c2162616d7 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -603,19 +603,42 @@ Find more information in [packet_filter_reference.md](packet_filter_reference.md **Usage:** - `get dutycycle` - `set dutycycle ` +- `set dutycycle auto` **Parameters:** -- `value`: Duty cycle percentage (1-100) +- `value`: Duty cycle percentage (1-100), or `auto` to follow the regulatory limit of the configured frequency -**Default:** `50%` (equivalent to airtime factor 1.0) +**Default:** `auto` + +In `auto` the limit comes from the sub-band the node is tuned to, and it is +re-derived as soon as `set freq` or `set radio` changes that frequency. The +table only covers the 863-870 MHz SRD band of ETSI EN 300 220-2; on any other +frequency `auto` means no limit. + +| Sub-band (MHz) | Limit | +| --- | --- | +| 863.0 - 865.0 | 0.1% | +| 865.0 - 868.0 | 1% | +| 868.0 - 868.6 | 1% | +| 868.7 - 869.2 | 0.1% | +| 869.4 - 869.65 | 10% | +| 869.7 - 870.0 | 1% | +| gaps between the rows above | 0.1% | + +A frequency exactly on a boundary takes the lower of the two sub-bands. + +Setting an explicit percentage, or setting `af`, turns `auto` off and that +value stays in force until `set dutycycle auto` restores it. `get dutycycle` +reports which of the two is active. **Examples:** +- `set dutycycle auto` — follow the sub-band of the configured frequency (default) - `set dutycycle 100` — no duty cycle limit -- `set dutycycle 50` — 50% duty cycle (default) +- `set dutycycle 50` — 50% duty cycle - `set dutycycle 10` — 10% duty cycle -- `set dutycycle 1` — 1% duty cycle (strictest EU requirement) +- `set dutycycle 1` — 1% duty cycle -> **Note:** Added in firmware v1.15.0 +> **Note:** Added in firmware v1.15.0. `auto`, and the default changing from 50% to `auto`, added in the DMC fork. --- @@ -634,7 +657,11 @@ Find more information in [packet_filter_reference.md](packet_filter_reference.md - `af = 9` → ~10% duty You are responsible for choosing a value that is appropriate for your jurisdiction and channel plan (for example EU 868 Mhz 10% duty cycle regulation). -**Default:** `1.0` + Setting `af` turns off `set dutycycle auto`. `get af` reports the factor + that is actually in force, so while `dutycycle` is on `auto` it reports the + one derived from the frequency rather than the stored value. + +**Default:** `1.0`, used only while `dutycycle` is not on `auto` --- diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 2c33406632..769b3b7bf6 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,4 +1,5 @@ #include "MyMesh.h" +#include #include // needed for PlatformIO #include @@ -255,7 +256,7 @@ int MyMesh::getFromOfflineQueue(uint8_t frame[]) { } float MyMesh::getAirtimeBudgetFactor() const { - return _prefs.airtime_factor; + return getEffectiveAirtimeFactor(_prefs.dutycycle_auto, _prefs.airtime_factor, _prefs.freq); } int MyMesh::getInterferenceThreshold() const { @@ -1431,10 +1432,12 @@ void MyMesh::handleCmdFrame(size_t len) { i += 4; _prefs.rx_delay_base = ((float)rx) / 1000.0f; _prefs.airtime_factor = ((float)af) / 1000.0f; + _prefs.dutycycle_auto = 0; // an explicit airtime factor takes over from the derived limit savePrefs(); writeOKFrame(); } else if (cmd_frame[0] == CMD_GET_TUNING_PARAMS) { - uint32_t rx = _prefs.rx_delay_base * 1000, af = _prefs.airtime_factor * 1000; + uint32_t rx = _prefs.rx_delay_base * 1000; + uint32_t af = getEffectiveAirtimeFactor(_prefs.dutycycle_auto, _prefs.airtime_factor, _prefs.freq) * 1000; int i = 0; out_frame[i++] = RESP_CODE_TUNING_PARAMS; memcpy(&out_frame[i], &rx, 4); i += 4; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 21766de82d..6eac0a5df0 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -35,6 +35,7 @@ class NodePrefs : public ConfigSerializer { // persisted to file uint8_t rx_boosted_gain = 0; // SX126x RX boosted gain mode (0=power saving, 1=boosted) uint8_t radio_fem_rxgain = 0; // external LoRa FEM RX gain (LNA) uint8_t radio_fem_txgain = 0; // external LoRa FEM TX gain (low by default) + uint8_t dutycycle_auto = 1; // derive the duty cycle limit from freq (boolean) uint8_t _client_repeat = 0; // DEPRECATED -> use repeat.disable_fwd uint8_t path_hash_mode = 0; // which path mode to use when sending uint8_t autoadd_max_hops = 0; // 0 = no limit, 1 = direct (0 hops), N = up to N-1 hops (max 64) @@ -61,6 +62,7 @@ class NodePrefs : public ConfigSerializer { // persisted to file #endif def("tx", _parent->tx_power_dbm); def("af", _parent->airtime_factor); + def("dc_auto", _parent->dutycycle_auto); def("rxdelay", _parent->rx_delay_base); //def("f_txdelay", _parent->tx_delay_factor); currently hard-coded //def("d_txdelay", _parent->direct_tx_delay_factor); currently hard-coded diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 70460f3dde..a7fc190893 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -4,6 +4,7 @@ #include #include #include +#include #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #include @@ -135,7 +136,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { protected: float getAirtimeBudgetFactor() const override { - return _prefs.airtime_factor; + return getEffectiveAirtimeFactor(_prefs.dutycycle_auto, _prefs.airtime_factor, _prefs.freq); } bool allowPacketForward(const mesh::Packet* packet) override; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 5cf949c6bd..2c97884fd7 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -130,7 +131,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { protected: float getAirtimeBudgetFactor() const override { - return _prefs.airtime_factor; + return getEffectiveAirtimeFactor(_prefs.dutycycle_auto, _prefs.airtime_factor, _prefs.freq); } void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; diff --git a/examples/simple_secure_chat/main.cpp b/examples/simple_secure_chat/main.cpp index da42ddcbbd..706f73697e 100644 --- a/examples/simple_secure_chat/main.cpp +++ b/examples/simple_secure_chat/main.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -67,7 +68,8 @@ struct NodePrefs { // persisted to file double node_lat, node_lon; float freq; int8_t tx_power_dbm; - uint8_t unused[3]; + uint8_t dutycycle_auto; + uint8_t unused[2]; }; class MyMesh : public BaseChatMesh, ContactVisitor { @@ -191,7 +193,7 @@ class MyMesh : public BaseChatMesh, ContactVisitor { protected: float getAirtimeBudgetFactor() const override { - return _prefs.airtime_factor; + return getEffectiveAirtimeFactor(_prefs.dutycycle_auto, _prefs.airtime_factor, _prefs.freq); } int calcRxDelay(float score, uint32_t air_time) const override { @@ -285,6 +287,7 @@ class MyMesh : public BaseChatMesh, ContactVisitor { strcpy(_prefs.node_name, "NONAME"); _prefs.freq = LORA_FREQ; _prefs.tx_power_dbm = LORA_TX_POWER; + _prefs.dutycycle_auto = 1; command[0] = 0; curr_recipient = NULL; @@ -476,6 +479,7 @@ class MyMesh : public BaseChatMesh, ContactVisitor { const char* config = &command[4]; if (memcmp(config, "af ", 3) == 0) { _prefs.airtime_factor = atof(&config[3]); + _prefs.dutycycle_auto = 0; savePrefs(); Serial.println(" OK"); } else if (memcmp(config, "name ", 5) == 0) { diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 9bfa5ec6a0..65996735c6 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -1,4 +1,5 @@ #include "SensorMesh.h" +#include /* ------------------------------ Config -------------------------------- */ @@ -298,7 +299,7 @@ void SensorMesh::alertIf(bool condition, Trigger& t, AlertPriority pri, const ch } float SensorMesh::getAirtimeBudgetFactor() const { - return _prefs.airtime_factor; + return getEffectiveAirtimeFactor(_prefs.dutycycle_auto, _prefs.airtime_factor, _prefs.freq); } bool SensorMesh::allowPacketForward(const mesh::Packet* packet) { diff --git a/platformio.ini b/platformio.ini index e78124a40b..f9b777cb4a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -170,6 +170,7 @@ build_src_filter = +<../src/Utils.cpp> +<../src/Packet.cpp> +<../src/helpers/ConfigSerializer.cpp> + +<../src/helpers/DutyCycleLimits.cpp> lib_deps = google/googletest @ 1.17.0 diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index b318bb58e8..ac9169c052 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -1,5 +1,6 @@ #include #include "CommonCLI.h" +#include "DutyCycleLimits.h" #include "TxtDataHelpers.h" #include "AdvertDataHelpers.h" #include "TxtDataHelpers.h" @@ -448,19 +449,30 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* reply) { const char* config = &command[4]; if (memcmp(config, "dutycycle ", 10) == 0) { - float dc = atof(&config[10]); - if (dc < 1 || dc > 100) { - strcpy(reply, "ERROR: dutycycle must be 1-100"); - } else { - _prefs->airtime_factor = (100.0f / dc) - 1.0f; + if (memcmp(&config[10], "auto", 4) == 0) { + _prefs->dutycycle_auto = 1; savePrefs(); - float actual = 100.0f / (_prefs->airtime_factor + 1.0f); + float actual = getMaxDutyCyclePercent(_prefs->freq); int a_int = (int)actual; int a_frac = (int)((actual - a_int) * 10.0f + 0.5f); - sprintf(reply, "OK - %d.%d%%", a_int, a_frac); + sprintf(reply, "OK - auto, %d.%d%%", a_int, a_frac); + } else { + float dc = atof(&config[10]); + if (dc < 1 || dc > 100) { + strcpy(reply, "ERROR: dutycycle must be 1-100, or auto"); + } else { + _prefs->airtime_factor = dutyCycleToAirtimeFactor(dc); + _prefs->dutycycle_auto = 0; + savePrefs(); + float actual = 100.0f / (_prefs->airtime_factor + 1.0f); + int a_int = (int)actual; + int a_frac = (int)((actual - a_int) * 10.0f + 0.5f); + sprintf(reply, "OK - %d.%d%%", a_int, a_frac); + } } } else if (memcmp(config, "af ", 3) == 0) { _prefs->airtime_factor = atof(&config[3]); + _prefs->dutycycle_auto = 0; savePrefs(); strcpy(reply, "OK"); } else if (memcmp(config, "int.thresh ", 11) == 0) { @@ -807,12 +819,14 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* reply) { const char* config = &command[4]; if (memcmp(config, "dutycycle", 9) == 0) { - float dc = 100.0f / (_prefs->airtime_factor + 1.0f); + float af = getEffectiveAirtimeFactor(_prefs->dutycycle_auto, _prefs->airtime_factor, _prefs->freq); + float dc = 100.0f / (af + 1.0f); int dc_int = (int)dc; int dc_frac = (int)((dc - dc_int) * 10.0f + 0.5f); - sprintf(reply, "> %d.%d%%", dc_int, dc_frac); + sprintf(reply, "> %d.%d%% (%s)", dc_int, dc_frac, _prefs->dutycycle_auto ? "auto" : "manual"); } else if (memcmp(config, "af", 2) == 0) { - sprintf(reply, "> %s", StrHelper::ftoa(_prefs->airtime_factor)); + float af = getEffectiveAirtimeFactor(_prefs->dutycycle_auto, _prefs->airtime_factor, _prefs->freq); + sprintf(reply, "> %s", StrHelper::ftoa(af)); } else if (memcmp(config, "int.thresh", 10) == 0) { sprintf(reply, "> %d", (uint32_t) _prefs->interference_threshold); } else if (memcmp(config, "cad", 3) == 0) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 237c758e9f..91dc70adea 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -69,6 +69,7 @@ class NodePrefs : public ConfigSerializer { uint8_t path_hash_mode = 0; // which path mode to use when sending uint8_t loop_detect = 0; uint8_t cad_enabled = 0; // hardware Channel Activity Detection before TX (boolean) + uint8_t dutycycle_auto = 1; // derive the duty cycle limit from freq (boolean) uint8_t extra_sf[4]; private: @@ -87,6 +88,7 @@ class NodePrefs : public ConfigSerializer { def("fem_txgain", _parent->radio_fem_txgain); def("tx", _parent->tx_power_dbm); def("af", _parent->airtime_factor); + def("dc_auto", _parent->dutycycle_auto); def("rxdelay", _parent->rx_delay_base); def("f_txdelay", _parent->tx_delay_factor); def("d_txdelay", _parent->direct_tx_delay_factor); diff --git a/src/helpers/DutyCycleLimits.cpp b/src/helpers/DutyCycleLimits.cpp new file mode 100644 index 0000000000..41f97f2b9f --- /dev/null +++ b/src/helpers/DutyCycleLimits.cpp @@ -0,0 +1,49 @@ +#include "DutyCycleLimits.h" + +#define SRD_BAND_START_MHZ 863.0f +#define SRD_BAND_END_MHZ 870.0f + +// Applied inside the SRD band to any frequency that no sub-band below covers. +// Those gaps are the alarm and social alarm allocations, which carry their own +// restrictions, so the tightest limit of the band is used rather than none. +#define SRD_BAND_FALLBACK 0.1f + +struct SubBand { + float start_mhz; + float end_mhz; + float max_duty_cycle; +}; + +// ETSI EN 300 220-2, the sub-bands MeshCore presets are tuned to. A frequency +// that sits exactly on a boundary matches the first entry it falls in, which +// is the lower and therefore more restrictive of the two. +static const SubBand SUB_BANDS[] = { + { 863.0f, 865.0f, 0.1f }, + { 865.0f, 868.0f, 1.0f }, + { 868.0f, 868.6f, 1.0f }, + { 868.7f, 869.2f, 0.1f }, + { 869.4f, 869.65f, 10.0f }, + { 869.7f, 870.0f, 1.0f }, +}; + +float getMaxDutyCyclePercent(float freq_mhz) { + if (freq_mhz < SRD_BAND_START_MHZ || freq_mhz > SRD_BAND_END_MHZ) { + return DUTY_CYCLE_UNLIMITED; + } + for (int i = 0; i < (int)(sizeof(SUB_BANDS) / sizeof(SUB_BANDS[0])); i++) { + if (freq_mhz >= SUB_BANDS[i].start_mhz && freq_mhz <= SUB_BANDS[i].end_mhz) { + return SUB_BANDS[i].max_duty_cycle; + } + } + return SRD_BAND_FALLBACK; +} + +float dutyCycleToAirtimeFactor(float percent) { + return (100.0f / percent) - 1.0f; +} + +float getEffectiveAirtimeFactor(uint8_t dutycycle_auto, float airtime_factor, float freq_mhz) { + if (!dutycycle_auto) return airtime_factor; + + return dutyCycleToAirtimeFactor(getMaxDutyCyclePercent(freq_mhz)); +} diff --git a/src/helpers/DutyCycleLimits.h b/src/helpers/DutyCycleLimits.h new file mode 100644 index 0000000000..843df1c331 --- /dev/null +++ b/src/helpers/DutyCycleLimits.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +// Regulatory duty cycle limits, derived from the frequency a node is tuned to. +// +// Only the 863 to 870 MHz SRD band is constrained here, using the sub-band +// table of ETSI EN 300 220-2. Every other frequency is reported as +// DUTY_CYCLE_UNLIMITED, so US, ANZ and any other region keep the behaviour +// they had before this table existed. + +#define DUTY_CYCLE_UNLIMITED 100.0f + +// Highest duty cycle (percent) allowed on freq_mhz. +float getMaxDutyCyclePercent(float freq_mhz); + +// Duty cycle percentage to the airtime budget factor Dispatcher works with, +// where duty_cycle = 1 / (1 + factor). +float dutyCycleToAirtimeFactor(float percent); + +// Airtime budget factor a node should use: derived from its frequency while +// dutycycle_auto is set, otherwise the factor its operator configured. +float getEffectiveAirtimeFactor(uint8_t dutycycle_auto, float airtime_factor, float freq_mhz); diff --git a/test/test_duty_cycle_limits/test_duty_cycle_limits.cpp b/test/test_duty_cycle_limits/test_duty_cycle_limits.cpp new file mode 100644 index 0000000000..35dcf15ef3 --- /dev/null +++ b/test/test_duty_cycle_limits/test_duty_cycle_limits.cpp @@ -0,0 +1,52 @@ +#include +#include "helpers/DutyCycleLimits.h" + +TEST(DutyCycleLimits, UnregulatedOutsideSrdBand) { + EXPECT_FLOAT_EQ(DUTY_CYCLE_UNLIMITED, getMaxDutyCyclePercent(433.0f)); + EXPECT_FLOAT_EQ(DUTY_CYCLE_UNLIMITED, getMaxDutyCyclePercent(862.9f)); + EXPECT_FLOAT_EQ(DUTY_CYCLE_UNLIMITED, getMaxDutyCyclePercent(870.1f)); + EXPECT_FLOAT_EQ(DUTY_CYCLE_UNLIMITED, getMaxDutyCyclePercent(910.525f)); + EXPECT_FLOAT_EQ(DUTY_CYCLE_UNLIMITED, getMaxDutyCyclePercent(915.0f)); +} + +TEST(DutyCycleLimits, SubBandLimits) { + EXPECT_FLOAT_EQ(0.1f, getMaxDutyCyclePercent(864.0f)); + EXPECT_FLOAT_EQ(1.0f, getMaxDutyCyclePercent(867.0f)); + EXPECT_FLOAT_EQ(1.0f, getMaxDutyCyclePercent(868.3f)); + EXPECT_FLOAT_EQ(0.1f, getMaxDutyCyclePercent(869.0f)); + EXPECT_FLOAT_EQ(10.0f, getMaxDutyCyclePercent(869.525f)); + EXPECT_FLOAT_EQ(10.0f, getMaxDutyCyclePercent(869.618f)); // the shipped LORA_FREQ default + EXPECT_FLOAT_EQ(1.0f, getMaxDutyCyclePercent(869.8f)); +} + +TEST(DutyCycleLimits, BoundaryTakesTheLowerSubBand) { + EXPECT_FLOAT_EQ(0.1f, getMaxDutyCyclePercent(863.0f)); + EXPECT_FLOAT_EQ(0.1f, getMaxDutyCyclePercent(865.0f)); + EXPECT_FLOAT_EQ(1.0f, getMaxDutyCyclePercent(868.0f)); + EXPECT_FLOAT_EQ(10.0f, getMaxDutyCyclePercent(869.65f)); + EXPECT_FLOAT_EQ(1.0f, getMaxDutyCyclePercent(870.0f)); +} + +TEST(DutyCycleLimits, GapsInsideBandFallBackToTheTightestLimit) { + EXPECT_FLOAT_EQ(0.1f, getMaxDutyCyclePercent(868.65f)); + EXPECT_FLOAT_EQ(0.1f, getMaxDutyCyclePercent(869.3f)); + EXPECT_FLOAT_EQ(0.1f, getMaxDutyCyclePercent(869.68f)); +} + +TEST(DutyCycleLimits, AirtimeFactorConversion) { + EXPECT_FLOAT_EQ(99.0f, dutyCycleToAirtimeFactor(1.0f)); + EXPECT_FLOAT_EQ(9.0f, dutyCycleToAirtimeFactor(10.0f)); + EXPECT_FLOAT_EQ(1.0f, dutyCycleToAirtimeFactor(50.0f)); + EXPECT_FLOAT_EQ(0.0f, dutyCycleToAirtimeFactor(100.0f)); +} + +TEST(DutyCycleLimits, AutoDerivesTheFactorFromFreq) { + EXPECT_FLOAT_EQ(9.0f, getEffectiveAirtimeFactor(1, 1.0f, 869.618f)); + EXPECT_FLOAT_EQ(99.0f, getEffectiveAirtimeFactor(1, 1.0f, 868.3f)); + EXPECT_FLOAT_EQ(0.0f, getEffectiveAirtimeFactor(1, 1.0f, 915.0f)); +} + +TEST(DutyCycleLimits, ManualKeepsTheConfiguredFactor) { + EXPECT_FLOAT_EQ(1.0f, getEffectiveAirtimeFactor(0, 1.0f, 869.618f)); + EXPECT_FLOAT_EQ(0.5f, getEffectiveAirtimeFactor(0, 0.5f, 915.0f)); +}