From 5e69bc6c3ff990f5a1e79da8a9125116be7d1004 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Thu, 21 May 2026 16:20:09 +0100 Subject: [PATCH] Enable Narrow and Lite regions for EU (#10120) * Enable Lite and Narrow regions and introduce getEffectiveDutyCycle for Lite profiles * Add TrafficType enum and extend getConfiguredOrDefaultMsScaled to manage based on regionProfile settings * Refactor telemetry modules to include TrafficType in getConfiguredOrDefaultMsScaled calls * Update submodule protobufs to latest commit * Add support for new region presets and modem presets in menu options * Add new LoRa region codes and modem presets for EU bands * boof * Add modem presets for LITE and NARROW configurations * Update subproject commit reference in protobufs * Update protobufs * Refactor modem preset definitions to use macro for consistency and clarity * Refactor modem preset cases to use PRESET macro for consistency * fix: update LoRa region code for EU 868 narrowband configuration Co-authored-by: Copilot * Fix test suite failure Co-authored-by: Copilot * Add override slot override - for when one override isn't enough. Co-authored-by: Copilot * address copilot comments --------- Co-authored-by: Copilot --- src/DisplayFormatters.cpp | 31 ++- src/airtime.cpp | 7 +- src/graphics/draw/MenuHandler.cpp | 96 +++++---- src/graphics/draw/UIRenderer.cpp | 17 +- .../InkHUD/Applets/System/Menu/MenuAction.h | 7 + .../InkHUD/Applets/System/Menu/MenuApplet.cpp | 61 ++++-- src/mesh/Default.cpp | 20 ++ src/mesh/Default.h | 4 + src/mesh/MeshRadio.h | 62 ++++-- src/mesh/RadioInterface.cpp | 160 ++++++++------ src/mesh/Router.cpp | 7 +- src/modules/AdminModule.cpp | 2 +- src/modules/CannedMessageModule.cpp | 17 +- src/modules/PositionModule.cpp | 4 +- src/modules/Telemetry/AirQualityTelemetry.cpp | 21 +- src/modules/Telemetry/DeviceTelemetry.cpp | 2 +- .../Telemetry/EnvironmentTelemetry.cpp | 7 +- src/modules/Telemetry/HealthTelemetry.cpp | 7 +- src/modules/Telemetry/PowerTelemetry.cpp | 5 +- test/test_admin_radio/test_main.cpp | 196 +++++++++++++++++- 20 files changed, 554 insertions(+), 179 deletions(-) diff --git a/src/DisplayFormatters.cpp b/src/DisplayFormatters.cpp index fdcf840dc..13aded6a6 100644 --- a/src/DisplayFormatters.cpp +++ b/src/DisplayFormatters.cpp @@ -1,4 +1,5 @@ #include "DisplayFormatters.h" +#include "MeshRadio.h" const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset preset, bool useShortName, bool usePreset) @@ -11,33 +12,45 @@ const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaC } switch (preset) { - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: + case PRESET(SHORT_TURBO): return useShortName ? "ShortT" : "ShortTurbo"; break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: + case PRESET(SHORT_SLOW): return useShortName ? "ShortS" : "ShortSlow"; break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: + case PRESET(SHORT_FAST): return useShortName ? "ShortF" : "ShortFast"; break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: + case PRESET(MEDIUM_SLOW): return useShortName ? "MedS" : "MediumSlow"; break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: + case PRESET(MEDIUM_FAST): return useShortName ? "MedF" : "MediumFast"; break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: + case PRESET(LONG_SLOW): return useShortName ? "LongS" : "LongSlow"; break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: + case PRESET(LONG_FAST): return useShortName ? "LongF" : "LongFast"; break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO: + case PRESET(LONG_TURBO): return useShortName ? "LongT" : "LongTurbo"; break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: + case PRESET(LONG_MODERATE): return useShortName ? "LongM" : "LongMod"; break; + case PRESET(LITE_FAST): + return useShortName ? "LiteF" : "LiteFast"; + break; + case PRESET(LITE_SLOW): + return useShortName ? "LiteS" : "LiteSlow"; + break; + case PRESET(NARROW_FAST): + return useShortName ? "NarF" : "NarrowFast"; + break; + case PRESET(NARROW_SLOW): + return useShortName ? "NarS" : "NarrowSlow"; + break; default: return useShortName ? "Custom" : "Invalid"; break; diff --git a/src/airtime.cpp b/src/airtime.cpp index a7736d667..0e0d72e20 100644 --- a/src/airtime.cpp +++ b/src/airtime.cpp @@ -133,11 +133,12 @@ bool AirTime::isTxAllowedChannelUtil(bool polite) bool AirTime::isTxAllowedAirUtil() { - if (!config.lora.override_duty_cycle && myRegion->dutyCycle < 100) { - if (utilizationTXPercent() < myRegion->dutyCycle * polite_duty_cycle_percent / 100) { + float effectiveDutyCycle = getEffectiveDutyCycle(); + if (!config.lora.override_duty_cycle && effectiveDutyCycle < 100) { + if (utilizationTXPercent() < effectiveDutyCycle * polite_duty_cycle_percent / 100) { return true; } else { - LOG_WARN("TX air util. >%f%%. Skip send", myRegion->dutyCycle * polite_duty_cycle_percent / 100); + LOG_WARN("TX air util. >%f%%. Skip send", effectiveDutyCycle * polite_duty_cycle_percent / 100); return false; } } diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 9fc7fa410..279339acd 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -2,6 +2,7 @@ #if HAS_SCREEN #include "ClockRenderer.h" #include "Default.h" +#include "DisplayFormatters.h" #include "GPS.h" #include "MenuHandler.h" #include "MeshRadio.h" @@ -180,6 +181,8 @@ void menuHandler::LoraRegionPicker(uint32_t duration) {"US", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_US}, {"EU_433", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_EU_433}, {"EU_868", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_EU_868}, + {"EU_866", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_EU_866}, + {"EU_868_NARROW", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_EU_N_868}, {"CN", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_CN}, {"JP", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_JP}, {"ANZ", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_ANZ}, @@ -203,6 +206,7 @@ void menuHandler::LoraRegionPicker(uint32_t duration) {"KZ_863", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_KZ_863}, {"NP_865", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_NP_865}, {"BR_902", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_BR_902}, + }; constexpr size_t regionCount = sizeof(regionOptions) / sizeof(regionOptions[0]); @@ -244,7 +248,7 @@ void menuHandler::LoraRegionPicker(uint32_t duration) #endif config.lora.tx_enabled = true; initRegion(); - if (myRegion->dutyCycle < 100) { + if (getEffectiveDutyCycle() < 100) { config.lora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit } @@ -378,42 +382,64 @@ void menuHandler::FrequencySlotPicker() screen->showOverlayBanner(bannerOptions); } +// Maximum presets any region can have + 1 for Back +static constexpr int MAX_PRESET_OPTIONS = 16; + +static BannerOverlayOptions buildRegionPresetBanner() +{ + // Static storage reused each call — safe because the banner is shown immediately after. + static const char *optionsArray[MAX_PRESET_OPTIONS]; + static int optionsEnumArray[MAX_PRESET_OPTIONS]; + static char presetLabelBuf[MAX_PRESET_OPTIONS][12]; // scratch space for name copies + int count = 0; + + optionsArray[count] = "Back"; + optionsEnumArray[count++] = -1; + + if (myRegion && myRegion->profile) { + const meshtastic_Config_LoRaConfig_ModemPreset *presets = myRegion->getAvailablePresets(); + size_t numPresets = myRegion->getNumPresets(); + for (size_t i = 0; i < numPresets && count < MAX_PRESET_OPTIONS; ++i) { + const char *name = DisplayFormatters::getModemPresetDisplayName(presets[i], false, true); + strncpy(presetLabelBuf[count], name, sizeof(presetLabelBuf[count]) - 1); + presetLabelBuf[count][sizeof(presetLabelBuf[count]) - 1] = '\0'; + optionsArray[count] = presetLabelBuf[count]; + optionsEnumArray[count++] = static_cast(presets[i]); + } + } + + int initialSelection = 0; + for (int i = 1; i < count; ++i) { + if (optionsEnumArray[i] == static_cast(config.lora.modem_preset)) { + initialSelection = i; + break; + } + } + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Radio Preset"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsEnumPtr = optionsEnumArray; + bannerOptions.optionsCount = static_cast(count); + bannerOptions.InitialSelected = initialSelection; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == -1) { + menuHandler::menuQueue = menuHandler::LoraMenu; + screen->runNow(); + return; + } + config.lora.use_preset = true; + config.lora.modem_preset = static_cast(selected); + config.lora.channel_num = 0; // Reset to default channel for the preset + config.lora.override_frequency = 0; // Clear any custom frequency + service->reloadConfig(SEGMENT_CONFIG); + }; + return bannerOptions; +} + void menuHandler::radioPresetPicker() { - static const RadioPresetOption presetOptions[] = { - {"Back", OptionsAction::Back}, - {"LongTurbo", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO}, - {"LongModerate", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE}, - {"LongFast", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST}, - {"MediumSlow", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW}, - {"MediumFast", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST}, - {"ShortSlow", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW}, - {"ShortFast", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST}, - {"ShortTurbo", OptionsAction::Select, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO}, - }; - - constexpr size_t presetCount = sizeof(presetOptions) / sizeof(presetOptions[0]); - static std::array presetLabels{}; - - auto bannerOptions = - createStaticBannerOptions("Radio Preset", presetOptions, presetLabels, [](const RadioPresetOption &option, int) -> void { - if (option.action == OptionsAction::Back) { - menuHandler::menuQueue = menuHandler::LoraMenu; - screen->runNow(); - return; - } - - if (!option.hasValue) { - return; - } - - config.lora.modem_preset = option.value; - config.lora.channel_num = 0; // Reset to default channel for the preset - config.lora.override_frequency = 0; // Clear any custom frequency - service->reloadConfig(SEGMENT_CONFIG); - }); - - screen->showOverlayBanner(bannerOptions); + screen->showOverlayBanner(buildRegionPresetBanner()); } void menuHandler::twelveHourPicker() diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index e7b00e5d5..f64d4a436 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -2,6 +2,7 @@ #if HAS_SCREEN #include "CompassRenderer.h" #include "GPSStatus.h" +#include "MeshRadio.h" #include "MeshService.h" #include "NodeDB.h" #include "NodeListRenderer.h" @@ -816,16 +817,16 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat // Helper to get SNR limit based on modem preset auto getSnrLimit = [](meshtastic_Config_LoRaConfig_ModemPreset preset) -> float { switch (preset) { - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: + case PRESET(LONG_SLOW): + case PRESET(LONG_MODERATE): + case PRESET(LONG_FAST): return -6.0f; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: + case PRESET(MEDIUM_SLOW): + case PRESET(MEDIUM_FAST): return -5.5f; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: + case PRESET(SHORT_SLOW): + case PRESET(SHORT_FAST): + case PRESET(SHORT_TURBO): return -4.5f; default: return -6.0f; diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h index e1f004d38..fc1aef02a 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h @@ -64,6 +64,8 @@ enum MenuAction { SET_REGION_KZ_863, SET_REGION_NP_865, SET_REGION_BR_902, + SET_REGION_EU_866, + SET_REGION_NARROW_868, // Device Roles SET_ROLE_CLIENT, SET_ROLE_CLIENT_MUTE, @@ -78,6 +80,11 @@ enum MenuAction { SET_PRESET_SHORT_SLOW, SET_PRESET_SHORT_FAST, SET_PRESET_SHORT_TURBO, + SET_PRESET_LITE_SLOW, + SET_PRESET_LITE_FAST, + SET_PRESET_NARROW_SLOW, + SET_PRESET_NARROW_FAST, + SET_PRESET_FROM_REGION, // Dynamic: preset chosen from region-available list // Timezones SET_TZ_US_HAWAII, SET_TZ_US_ALASKA, diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index 79ac1e701..b70853151 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -4,6 +4,7 @@ #include "DisplayFormatters.h" #include "GPS.h" +#include "MeshRadio.h" #include "MeshService.h" #include "RTC.h" #include "Router.h" @@ -257,6 +258,11 @@ int32_t InkHUD::MenuApplet::runOnce() return OSThread::disable(); } +// Storage for the dynamically-built region preset list — populated in showPage(NODE_CONFIG_PRESET) +static constexpr uint8_t MAX_REGION_PRESETS = 16; +static meshtastic_Config_LoRaConfig_ModemPreset regionPresets[MAX_REGION_PRESETS]; +static uint8_t regionPresetCount = 0; + static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region) { if (config.lora.region == region) @@ -276,7 +282,7 @@ static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region) initRegion(); - if (myRegion && myRegion->dutyCycle < 100) { + if (myRegion && getEffectiveDutyCycle() < 100) { config.lora.ignore_mqtt = true; } @@ -770,6 +776,14 @@ void InkHUD::MenuApplet::execute(MenuItem item) applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_BR_902); break; + case SET_REGION_EU_866: + applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_866); + break; + + case SET_REGION_NARROW_868: + applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_N_868); + break; + // Roles case SET_ROLE_CLIENT: applyDeviceRole(meshtastic_Config_DeviceConfig_Role_CLIENT); @@ -789,37 +803,46 @@ void InkHUD::MenuApplet::execute(MenuItem item) // Presets case SET_PRESET_LONG_SLOW: - applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW); + applyLoRaPreset(PRESET(LONG_SLOW)); break; case SET_PRESET_LONG_MODERATE: - applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE); + applyLoRaPreset(PRESET(LONG_MODERATE)); break; case SET_PRESET_LONG_FAST: - applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + applyLoRaPreset(PRESET(LONG_FAST)); break; case SET_PRESET_MEDIUM_SLOW: - applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW); + applyLoRaPreset(PRESET(MEDIUM_SLOW)); break; case SET_PRESET_MEDIUM_FAST: - applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); + applyLoRaPreset(PRESET(MEDIUM_FAST)); break; case SET_PRESET_SHORT_SLOW: - applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW); + applyLoRaPreset(PRESET(SHORT_SLOW)); break; case SET_PRESET_SHORT_FAST: - applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST); + applyLoRaPreset(PRESET(SHORT_FAST)); break; case SET_PRESET_SHORT_TURBO: - applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO); + applyLoRaPreset(PRESET(SHORT_TURBO)); break; + case SET_PRESET_FROM_REGION: { + // cursor - 1 because index 0 is "Back" + const uint8_t index = cursor - 1; + if (index < regionPresetCount) { + applyLoRaPreset(regionPresets[index]); + } + break; + } + // Timezones case SET_TZ_US_HAWAII: applyTimezone("HST10"); @@ -1421,6 +1444,8 @@ void InkHUD::MenuApplet::showPage(MenuPage page) items.push_back(MenuItem("US", MenuAction::SET_REGION_US, MenuPage::EXIT)); items.push_back(MenuItem("EU 868", MenuAction::SET_REGION_EU_868, MenuPage::EXIT)); items.push_back(MenuItem("EU 433", MenuAction::SET_REGION_EU_433, MenuPage::EXIT)); + items.push_back(MenuItem("EU 866", MenuAction::SET_REGION_EU_866, MenuPage::EXIT)); + items.push_back(MenuItem("EU 868 Narrow", MenuAction::SET_REGION_NARROW_868, MenuPage::EXIT)); items.push_back(MenuItem("CN", MenuAction::SET_REGION_CN, MenuPage::EXIT)); items.push_back(MenuItem("JP", MenuAction::SET_REGION_JP, MenuPage::EXIT)); items.push_back(MenuItem("ANZ", MenuAction::SET_REGION_ANZ, MenuPage::EXIT)); @@ -1450,13 +1475,17 @@ void InkHUD::MenuApplet::showPage(MenuPage page) case NODE_CONFIG_PRESET: { previousPage = MenuPage::NODE_CONFIG_LORA; items.push_back(MenuItem("Back", previousPage)); - items.push_back(MenuItem("Long Moderate", MenuAction::SET_PRESET_LONG_MODERATE, MenuPage::EXIT)); - items.push_back(MenuItem("Long Fast", MenuAction::SET_PRESET_LONG_FAST, MenuPage::EXIT)); - items.push_back(MenuItem("Medium Slow", MenuAction::SET_PRESET_MEDIUM_SLOW, MenuPage::EXIT)); - items.push_back(MenuItem("Medium Fast", MenuAction::SET_PRESET_MEDIUM_FAST, MenuPage::EXIT)); - items.push_back(MenuItem("Short Slow", MenuAction::SET_PRESET_SHORT_SLOW, MenuPage::EXIT)); - items.push_back(MenuItem("Short Fast", MenuAction::SET_PRESET_SHORT_FAST, MenuPage::EXIT)); - items.push_back(MenuItem("Short Turbo", MenuAction::SET_PRESET_SHORT_TURBO, MenuPage::EXIT)); + regionPresetCount = 0; + if (myRegion && myRegion->profile) { + const meshtastic_Config_LoRaConfig_ModemPreset *presets = myRegion->getAvailablePresets(); + size_t numPresets = myRegion->getNumPresets(); + for (size_t i = 0; i < numPresets && regionPresetCount < MAX_REGION_PRESETS; ++i) { + regionPresets[regionPresetCount++] = presets[i]; + const char *name = DisplayFormatters::getModemPresetDisplayName(presets[i], false, true); + nodeConfigLabels.emplace_back(name); + items.push_back(MenuItem(nodeConfigLabels.back().c_str(), MenuAction::SET_PRESET_FROM_REGION, MenuPage::EXIT)); + } + } items.push_back(MenuItem("Exit", MenuPage::EXIT)); break; } diff --git a/src/mesh/Default.cpp b/src/mesh/Default.cpp index 7a2d9e410..67331f6ce 100644 --- a/src/mesh/Default.cpp +++ b/src/mesh/Default.cpp @@ -60,6 +60,26 @@ uint32_t Default::getConfiguredOrDefaultMsScaled(uint32_t configured, uint32_t d return base * coef; } +uint32_t Default::getConfiguredOrDefaultMsScaled(uint32_t configured, uint32_t defaultValue, uint32_t numOnlineNodes, + TrafficType type) +{ + uint32_t baseMs = getConfiguredOrDefaultMsScaled(configured, defaultValue, numOnlineNodes); + + if (!myRegion || !myRegion->profile) + return baseMs; + + int8_t throttle = + (type == TrafficType::POSITION) ? myRegion->profile->positionThrottle : myRegion->profile->telemetryThrottle; + + // throttle <= 0 means unset; 1 is the neutral multiplier — skip the multiply for performance + if (throttle <= 1) + return baseMs; + + constexpr uint32_t MAX_MS = static_cast(INT32_MAX); + uint64_t result = static_cast(baseMs) * static_cast(throttle); + return result >= static_cast(MAX_MS) ? MAX_MS : static_cast(result); +} + uint32_t Default::getConfiguredOrMinimumValue(uint32_t configured, uint32_t minValue) { // If zero, intervals should be coalesced later by getConfiguredOrDefault... methods diff --git a/src/mesh/Default.h b/src/mesh/Default.h index 59425042e..b1ebf5f2f 100644 --- a/src/mesh/Default.h +++ b/src/mesh/Default.h @@ -31,6 +31,8 @@ #define min_neighbor_info_broadcast_secs 4 * 60 * 60 #define default_map_publish_interval_secs 60 * 60 +enum class TrafficType { POSITION, TELEMETRY }; + // Traffic management defaults #define default_traffic_mgmt_position_precision_bits 24 // ~10m grid cells #define default_traffic_mgmt_position_min_interval_secs (ONE_DAY / 2) // 12 hours between identical positions @@ -64,6 +66,8 @@ class Default // Note: numOnlineNodes uses uint32_t to match the public API and allow flexibility, // even though internal node counts use uint16_t (max 65535 nodes) static uint32_t getConfiguredOrDefaultMsScaled(uint32_t configured, uint32_t defaultValue, uint32_t numOnlineNodes); + static uint32_t getConfiguredOrDefaultMsScaled(uint32_t configured, uint32_t defaultValue, uint32_t numOnlineNodes, + TrafficType type); static uint8_t getConfiguredOrDefaultHopLimit(uint8_t configured); static uint32_t getConfiguredOrMinimumValue(uint32_t configured, uint32_t minValue); diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index fe4788bff..e2c053a8b 100644 --- a/src/mesh/MeshRadio.h +++ b/src/mesh/MeshRadio.h @@ -13,9 +13,16 @@ static const meshtastic_Config_LoRaConfig_ModemPreset MODEM_PRESET_END = static_cast(0xFF); +#define PRESET(name) meshtastic_Config_LoRaConfig_ModemPreset_##name + +// Override slot magic numbers for RegionProfile.overrideSlot +#define OVERRIDE_SLOT_DEFAULT_CHANNEL_HASH 0 // Use hash of primary channel name +#define OVERRIDE_SLOT_PRESET_HASH -1 // Use hash of preset name instead +// Positive values (1-32767) are explicit slot numbers + // Region profile: bundles the preset list with regulatory parameters shared across regions struct RegionProfile { - const meshtastic_Config_LoRaConfig_ModemPreset *presets; // sentinel-terminated; first entry is the default + const meshtastic_Config_LoRaConfig_ModemPreset *presets; // sentinel-terminated float spacing; // gaps between radio channels float padding; // padding at each side of the "operating channel" bool audioPermitted; @@ -23,14 +30,22 @@ struct RegionProfile { int8_t textThrottle; // throttle for text - future expansion int8_t positionThrottle; // throttle for location data - future expansion int8_t telemetryThrottle; // throttle for telemetry - future expansion - uint8_t overrideSlot; // a per-region override slot for if we need to fix it in place + int16_t overrideSlot; // a per-region override slot for if we need to fix it in place + // Magic values: 0 = use channel name hash, -1 = use preset name hash, >0 = explicit slot }; +/** + * Get the effective duty cycle for the current region based on device role. + * For EU_866, returns 10% for fixed devices (ROUTER, ROUTER_LATE) and 2.5% for mobile devices. + * For other regions, returns the standard duty cycle. + */ +extern float getEffectiveDutyCycle(); + extern const RegionProfile PROFILE_STD; extern const RegionProfile PROFILE_EU868; extern const RegionProfile PROFILE_UNDEF; -// extern const RegionProfile PROFILE_LITE; -// extern const RegionProfile PROFILE_NARROW; +extern const RegionProfile PROFILE_LITE; +extern const RegionProfile PROFILE_NARROW; // extern const RegionProfile PROFILE_HAM; // Map from old region names to new region enums @@ -43,10 +58,11 @@ struct RegionInfo { bool freqSwitching; bool wideLora; const RegionProfile *profile; + meshtastic_Config_LoRaConfig_ModemPreset defaultPreset; const char *name; // EU433 etc // Preset accessors (delegate through profile) - meshtastic_Config_LoRaConfig_ModemPreset getDefaultPreset() const { return profile->presets[0]; } + meshtastic_Config_LoRaConfig_ModemPreset getDefaultPreset() const { return defaultPreset; } const meshtastic_Config_LoRaConfig_ModemPreset *getAvailablePresets() const { return profile->presets; } size_t getNumPresets() const { @@ -143,46 +159,66 @@ static inline void modemPresetToParams(meshtastic_Config_LoRaConfig_ModemPreset uint8_t &cr) { switch (preset) { - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: + case PRESET(SHORT_TURBO): bwKHz = wideLora ? 1625.0f : 500.0f; cr = 5; sf = 7; break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: + case PRESET(SHORT_FAST): bwKHz = wideLora ? 812.5f : 250.0f; cr = 5; sf = 7; break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: + case PRESET(SHORT_SLOW): bwKHz = wideLora ? 812.5f : 250.0f; cr = 5; sf = 8; break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: + case PRESET(MEDIUM_FAST): bwKHz = wideLora ? 812.5f : 250.0f; cr = 5; sf = 9; break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: + case PRESET(MEDIUM_SLOW): bwKHz = wideLora ? 812.5f : 250.0f; cr = 5; sf = 10; break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO: + case PRESET(LONG_TURBO): bwKHz = wideLora ? 1625.0f : 500.0f; cr = 8; sf = 11; break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: + case PRESET(LONG_MODERATE): bwKHz = wideLora ? 406.25f : 125.0f; cr = 8; sf = 11; break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: + case PRESET(LONG_SLOW): bwKHz = wideLora ? 406.25f : 125.0f; cr = 8; sf = 12; break; + case PRESET(LITE_FAST): + bwKHz = 125; + cr = 5; + sf = 9; + break; + case PRESET(LITE_SLOW): + bwKHz = 125; + cr = 5; + sf = 10; + break; + case PRESET(NARROW_FAST): + bwKHz = 62.5f; + cr = 6; + sf = 7; + break; + case PRESET(NARROW_SLOW): + bwKHz = 62.5f; + cr = 6; + sf = 8; + break; default: // LONG_FAST (or illegal) bwKHz = wideLora ? 812.5f : 250.0f; cr = 5; diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index f4f25f80c..d4eeed089 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -35,31 +35,32 @@ #endif static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_STD[] = { - meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, - meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, - meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, - meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, - meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, MODEM_PRESET_END}; + PRESET(LONG_FAST), PRESET(LONG_SLOW), PRESET(MEDIUM_SLOW), PRESET(MEDIUM_FAST), PRESET(SHORT_SLOW), + PRESET(SHORT_FAST), PRESET(LONG_MODERATE), PRESET(SHORT_TURBO), PRESET(LONG_TURBO), MODEM_PRESET_END}; static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_EU_868[] = { - meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, - meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, - meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, - meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, MODEM_PRESET_END}; + PRESET(LONG_FAST), PRESET(LONG_SLOW), PRESET(MEDIUM_SLOW), PRESET(MEDIUM_FAST), + PRESET(SHORT_SLOW), PRESET(SHORT_FAST), PRESET(LONG_MODERATE), MODEM_PRESET_END}; -static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_UNDEF[] = {meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, - MODEM_PRESET_END}; +static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_UNDEF[] = {PRESET(LONG_FAST), MODEM_PRESET_END}; + +static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_LITE[] = {PRESET(LITE_FAST), PRESET(LITE_SLOW), MODEM_PRESET_END}; + +static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_NARROW[] = {PRESET(NARROW_FAST), PRESET(NARROW_SLOW), + MODEM_PRESET_END}; // Region profiles: bundle preset list + regulatory parameters shared across regions // presets, spacing, padding, audio, licensed, text throttle, position throttle, telemetry throttle, override slot -const RegionProfile PROFILE_STD = {PRESETS_STD, 0, 0, true, false, 0, 0, 0, 0}; -const RegionProfile PROFILE_EU868 = {PRESETS_EU_868, 0, 0, false, false, 0, 0, 0, 0}; -const RegionProfile PROFILE_UNDEF = {PRESETS_UNDEF, 0, 0, true, false, 0, 0, 0, 0}; +const RegionProfile PROFILE_STD = {PRESETS_STD, 0, 0, true, false, 0, 1, 1, 0}; +const RegionProfile PROFILE_EU868 = {PRESETS_EU_868, 0, 0, false, false, 0, 1, 1, 0}; +const RegionProfile PROFILE_UNDEF = {PRESETS_UNDEF, 0, 0, true, false, 0, 1, 1, 0}; +const RegionProfile PROFILE_LITE = {PRESETS_LITE, 0.4, 0.0375f, false, false, 0, 10, 10, 0}; +const RegionProfile PROFILE_NARROW = {PRESETS_NARROW, 0, 0.0104f, true, false, 0, 1, 1, 1}; -#define RDEF(name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, wide_lora, profile_ptr) \ +#define RDEF(name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, wide_lora, profile_ptr, default_preset) \ { \ meshtastic_Config_LoRaConfig_RegionCode_##name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, \ - wide_lora, &profile_ptr, #name \ + wide_lora, &profile_ptr, default_preset, #name \ } const RegionInfo regions[] = { @@ -67,7 +68,7 @@ const RegionInfo regions[] = { https://link.springer.com/content/pdf/bbm%3A978-1-4842-4357-2%2F1.pdf https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/ */ - RDEF(US, 902.0f, 928.0f, 100, 30, false, false, PROFILE_STD), + RDEF(US, 902.0f, 928.0f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* EN300220 ETSI V3.2.1 [Table B.1, Item H, p. 21] @@ -75,7 +76,7 @@ const RegionInfo regions[] = { https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.02.01_60/en_30022002v030201p.pdf FIXME: https://github.com/meshtastic/firmware/issues/3371 */ - RDEF(EU_433, 433.0f, 434.0f, 10, 10, false, false, PROFILE_STD), + RDEF(EU_433, 433.0f, 434.0f, 10, 10, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* https://www.thethingsnetwork.org/docs/lorawan/duty-cycle/ https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/ @@ -90,33 +91,33 @@ const RegionInfo regions[] = { AFA) to avoid a duty cycle. (Please refer to line P page 22 of this document.) https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.01.01_60/en_30022002v030101p.pdf */ - RDEF(EU_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_EU868), + RDEF(EU_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_EU868, PRESET(LONG_FAST)), /* https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf */ - RDEF(CN, 470.0f, 510.0f, 100, 19, false, false, PROFILE_STD), + RDEF(CN, 470.0f, 510.0f, 100, 19, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf https://www.arib.or.jp/english/html/overview/doc/5-STD-T108v1_5-E1.pdf https://qiita.com/ammo0613/items/d952154f1195b64dc29f */ - RDEF(JP, 920.5f, 923.5f, 100, 13, false, false, PROFILE_STD), + RDEF(JP, 920.5f, 923.5f, 100, 13, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* https://www.iot.org.au/wp/wp-content/uploads/2016/12/IoTSpectrumFactSheet.pdf https://iotalliance.org.nz/wp-content/uploads/sites/4/2019/05/IoT-Spectrum-in-NZ-Briefing-Paper.pdf Also used in Brazil. */ - RDEF(ANZ, 915.0f, 928.0f, 100, 30, false, false, PROFILE_STD), + RDEF(ANZ, 915.0f, 928.0f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* 433.05 - 434.79 MHz, 25mW EIRP max, No duty cycle restrictions AU Low Interference Potential https://www.acma.gov.au/licences/low-interference-potential-devices-lipd-class-licence NZ General User Radio Licence for Short Range Devices https://gazette.govt.nz/notice/id/2022-go3100 */ - RDEF(ANZ_433, 433.05f, 434.79f, 100, 14, false, false, PROFILE_STD), + RDEF(ANZ_433, 433.05f, 434.79f, 100, 14, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* https://digital.gov.ru/uploaded/files/prilozhenie-12-k-reshenyu-gkrch-18-46-03-1.pdf @@ -124,13 +125,13 @@ const RegionInfo regions[] = { Note: - We do LBT, so 100% is allowed. */ - RDEF(RU, 868.7f, 869.2f, 100, 20, false, false, PROFILE_STD), + RDEF(RU, 868.7f, 869.2f, 100, 20, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulId=53943&efYd=0 https://resources.lora-alliance.org/technical-specifications/rp002-1-0-4-regional-parameters */ - RDEF(KR, 920.0f, 923.0f, 100, 23, false, false, PROFILE_STD), + RDEF(KR, 920.0f, 923.0f, 100, 23, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* Taiwan, 920-925Mhz, limited to 0.5W indoor or coastal, 1.0W outdoor. @@ -138,40 +139,40 @@ const RegionInfo regions[] = { https://www.ncc.gov.tw/english/files/23070/102_5190_230703_1_doc_C.PDF https://gazette.nat.gov.tw/egFront/e_detail.do?metaid=147283 */ - RDEF(TW, 920.0f, 925.0f, 100, 27, false, false, PROFILE_STD), + RDEF(TW, 920.0f, 925.0f, 100, 27, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf */ - RDEF(IN, 865.0f, 867.0f, 100, 30, false, false, PROFILE_STD), + RDEF(IN, 865.0f, 867.0f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* https://rrf.rsm.govt.nz/smart-web/smart/page/-smart/domain/licence/LicenceSummary.wdk?id=219752 https://iotalliance.org.nz/wp-content/uploads/sites/4/2019/05/IoT-Spectrum-in-NZ-Briefing-Paper.pdf */ - RDEF(NZ_865, 864.0f, 868.0f, 100, 36, false, false, PROFILE_STD), + RDEF(NZ_865, 864.0f, 868.0f, 100, 36, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf https://standard.nbtc.go.th/getattachment/Standards/%E0%B8%A1%E0%B8%B2%E0%B8%95%E0%B8%A3%E0%B8%90%E0%B8%B2%E0%B8%99%E0%B8%97%E0%B8%B2%E0%B8%87%E0%B9%80%E0%B8%97%E0%B8%84%E0%B8%99%E0%B8%B4%E0%B8%84%E0%B8%82%E0%B8%AD%E0%B8%87%E0%B9%80%E0%B8%84%E0%B8%A3%E0%B8%B7%E0%B9%88%E0%B8%AD%E0%B8%87%E0%B9%82%E0%B8%97%E0%B8%A3%E0%B8%84%E0%B8%A1%E0%B8%99%E0%B8%B2%E0%B8%84%E0%B8%A1/1033-2565.pdf.aspx?lang=th-TH Thailand 920–925 MHz set max TX power to 27 dBm and enforce 10% duty cycle, aligned with NBTC regulations. */ - RDEF(TH, 920.0f, 925.0f, 10, 27, false, false, PROFILE_STD), + RDEF(TH, 920.0f, 925.0f, 10, 27, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* 433,05-434,7 Mhz 10 mW 868,0-868,6 Mhz 25 mW https://nkrzi.gov.ua/images/upload/256/5810/PDF_UUZ_19_01_2016.pdf */ - RDEF(UA_433, 433.0f, 434.7f, 10, 10, false, false, PROFILE_STD), - RDEF(UA_868, 868.0f, 868.6f, 1, 14, false, false, PROFILE_STD), + RDEF(UA_433, 433.0f, 434.7f, 10, 10, false, false, PROFILE_STD, PRESET(LONG_FAST)), + RDEF(UA_868, 868.0f, 868.6f, 1, 14, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* Malaysia 433 - 435 MHz at 100mW, no restrictions. https://www.mcmc.gov.my/skmmgovmy/media/General/pdf/Short-Range-Devices-Specification.pdf */ - RDEF(MY_433, 433.0f, 435.0f, 100, 20, false, false, PROFILE_STD), + RDEF(MY_433, 433.0f, 435.0f, 100, 20, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* Malaysia @@ -180,14 +181,14 @@ const RegionInfo regions[] = { Frequency hopping is used for 919 - 923 MHz. https://www.mcmc.gov.my/skmmgovmy/media/General/pdf/Short-Range-Devices-Specification.pdf */ - RDEF(MY_919, 919.0f, 924.0f, 100, 27, true, false, PROFILE_STD), + RDEF(MY_919, 919.0f, 924.0f, 100, 27, true, false, PROFILE_STD, PRESET(LONG_FAST)), /* Singapore SG_923 Band 30d: 917 - 925 MHz at 100mW, no restrictions. https://www.imda.gov.sg/-/media/imda/files/regulation-licensing-and-consultations/ict-standards/telecommunication-standards/radio-comms/imdatssrd.pdf */ - RDEF(SG_923, 917.0f, 925.0f, 100, 20, false, false, PROFILE_STD), + RDEF(SG_923, 917.0f, 925.0f, 100, 20, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* Philippines @@ -197,9 +198,9 @@ const RegionInfo regions[] = { https://github.com/meshtastic/firmware/issues/4948#issuecomment-2394926135 */ - RDEF(PH_433, 433.0f, 434.7f, 100, 10, false, false, PROFILE_STD), - RDEF(PH_868, 868.0f, 869.4f, 100, 14, false, false, PROFILE_STD), - RDEF(PH_915, 915.0f, 918.0f, 100, 24, false, false, PROFILE_STD), + RDEF(PH_433, 433.0f, 434.7f, 100, 10, false, false, PROFILE_STD, PRESET(LONG_FAST)), + RDEF(PH_868, 868.0f, 869.4f, 100, 14, false, false, PROFILE_STD, PRESET(LONG_FAST)), + RDEF(PH_915, 915.0f, 918.0f, 100, 24, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* Kazakhstan @@ -207,32 +208,46 @@ const RegionInfo regions[] = { 863 - 868 MHz <25 mW EIRP, 500kHz channels allowed, must not be used at airfields https://github.com/meshtastic/firmware/issues/7204 */ - RDEF(KZ_433, 433.075f, 434.775f, 100, 10, false, false, PROFILE_STD), - RDEF(KZ_863, 863.0f, 868.0f, 100, 30, false, false, PROFILE_STD), + RDEF(KZ_433, 433.075f, 434.775f, 100, 10, false, false, PROFILE_STD, PRESET(LONG_FAST)), + RDEF(KZ_863, 863.0f, 868.0f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* Nepal 865 MHz to 868 MHz frequency band for IoT (Internet of Things), M2M (Machine-to-Machine), and smart metering use, specifically in non-cellular mode. https://www.nta.gov.np/uploads/contents/Radio-Frequency-Policy-2080-English.pdf */ - RDEF(NP_865, 865.0f, 868.0f, 100, 30, false, false, PROFILE_STD), + RDEF(NP_865, 865.0f, 868.0f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* Brazil 902 - 907.5 MHz , 1W power limit, no duty cycle restrictions https://github.com/meshtastic/firmware/issues/3741 */ - RDEF(BR_902, 902.0f, 907.5f, 100, 30, false, false, PROFILE_STD), + RDEF(BR_902, 902.0f, 907.5f, 100, 30, false, false, PROFILE_STD, PRESET(LONG_FAST)), /* 2.4 GHZ WLAN Band equivalent. Only for SX128x chips. */ - RDEF(LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, PROFILE_STD), + RDEF(LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, PROFILE_STD, PRESET(LONG_FAST)), + + /* + EU 866MHz band (Band no. 46b of 2006/771/EC and subsequent amendments) for Non-specific short-range devices (SRD) + Gives 4 channels at 865.7/866.3/866.9/867.5 MHz, 400 kHz gap plus 37.5 kHz padding between channels, 27 dBm, + duty cycle 2.5% (mobile) or 10% (fixed) https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:02006D0771(01)-20250123 + */ + RDEF(EU_866, 865.6f, 867.6f, 2.5, 27, false, false, PROFILE_LITE, PRESET(LITE_FAST)), + + /* + EU 868MHz band: 3 channels at 869.410/869.4625/869.577 MHz + Channel centres at 869.442/869.525/869.608 MHz, + 10.4 kHz padding on channels, 27 dBm, duty cycle 10% + */ + RDEF(EU_N_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_NARROW, PRESET(NARROW_SLOW)), /* This needs to be last. Same as US. */ - RDEF(UNSET, 902.0f, 928.0f, 100, 30, false, false, PROFILE_UNDEF) + RDEF(UNSET, 902.0f, 928.0f, 100, 30, false, false, PROFILE_UNDEF, PRESET(LONG_FAST)), }; @@ -546,6 +561,23 @@ const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code) return r; } +/** + * Get duty cycle for current region. EU_866: 10% for routers, 2.5% for mobile. + */ +float getEffectiveDutyCycle() +{ + if (myRegion->code == meshtastic_Config_LoRaConfig_RegionCode_EU_866) { + if (config.device.role == meshtastic_Config_DeviceConfig_Role_ROUTER || + config.device.role == meshtastic_Config_DeviceConfig_Role_ROUTER_LATE) { + return 10.0f; + } else { + return 2.5f; + } + } + // For all other regions, return the standard duty cycle + return myRegion->dutyCycle; +} + uint32_t RadioInterface::getPacketTime(const meshtastic_MeshPacket *p, bool received) { uint32_t pl = 0; @@ -897,12 +929,15 @@ bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraCo if (loraConfig.override_frequency == 0) { // Check if we use the default frequency slot + // overrideSlot: 0 = channel hash, -1 = preset hash, >0 = explicit slot uses_default_frequency_slot = (loraConfig.channel_num == 0) || // user choice unset, no frequency override, so use default - (newRegion->profile->overrideSlot != 0 && - loraConfig.channel_num == newRegion->profile->overrideSlot) || // user setting matches override - ((newRegion->profile->overrideSlot == 0) && - ((uint32_t)(loraConfig.channel_num - 1) == presetNameHashSlot)); // user setting matches preset hash, no override + (newRegion->profile->overrideSlot > 0 && + loraConfig.channel_num == newRegion->profile->overrideSlot) || // user setting matches explicit override slot + ((newRegion->profile->overrideSlot == OVERRIDE_SLOT_DEFAULT_CHANNEL_HASH) && + ((uint32_t)(loraConfig.channel_num - 1) == channelNameHashSlot)) || // user setting matches channel name hash + ((newRegion->profile->overrideSlot == OVERRIDE_SLOT_PRESET_HASH) && + ((uint32_t)(loraConfig.channel_num - 1) == presetNameHashSlot)); // user setting matches preset name hash // check if user setting different to preset name uses_custom_channel_name = (strcmp(channelName, presetNameDisplay) != 0); @@ -917,10 +952,14 @@ bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraCo if (clamp) { if (uses_custom_channel_name) { // clamp to channel name hash loraConfig.channel_num = - channelNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1 - } else if ((loraConfig.use_preset) && (newRegion->profile->overrideSlot != 0)) { // clamp to preset override slot + channelNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1 + } else if (newRegion->profile->overrideSlot > 0) { // clamp to explicit override slot loraConfig.channel_num = - newRegion->profile->overrideSlot; // use the override slot specified by the region profile + newRegion->profile->overrideSlot; // use the explicit override slot specified by the region profile + uses_default_frequency_slot = true; + } else if (newRegion->profile->overrideSlot == OVERRIDE_SLOT_PRESET_HASH && loraConfig.use_preset) { + // clamp to preset name hash + loraConfig.channel_num = presetNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1 uses_default_frequency_slot = true; } else if (loraConfig.use_preset) { // clamp to preset slot loraConfig.channel_num = presetNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1 @@ -1018,6 +1057,8 @@ void RadioInterface::applyModemConfig() // Calculate hash of channel name and preset name to pick a default frequency slot if user has not specified one. // Note that channel_num is actually (channel_num - 1), i.e. zero-based, since modulus (%) returns values from 0 to // (numFreqSlots - 1). + const char *channelName = channels.getName(channels.getPrimaryIndex()); + uint32_t channelNameHashSlot = hash(channelName) % numFreqSlots; uint32_t presetNameHashSlot = hash(DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset)) % numFreqSlots; @@ -1034,11 +1075,13 @@ void RadioInterface::applyModemConfig() // (channel_num - 1), i.e. zero-based, since modulus (%) returns values from 0 to (numFreqSlots - 1). // NB: channel_num is also know as frequency slot but it's too late to fix now. if (uses_default_frequency_slot) { - // if there's an override slot, use that - if (newRegion->profile->overrideSlot != 0) { - channel_num = newRegion->profile->overrideSlot - 1; + // Handle three override slot cases: explicit slot (>0), preset hash (-1), or channel hash (0) + if (newRegion->profile->overrideSlot > 0) { + channel_num = newRegion->profile->overrideSlot - 1; // explicit override slot (1-based to 0-based) + } else if (newRegion->profile->overrideSlot == OVERRIDE_SLOT_PRESET_HASH) { + channel_num = presetNameHashSlot; // use preset name hash } else { - channel_num = presetNameHashSlot; + channel_num = channelNameHashSlot; // use channel name hash (default case) } } else { // use the manually defined one channel_num = loraConfig.channel_num - 1; @@ -1051,7 +1094,6 @@ void RadioInterface::applyModemConfig() saveChannelNum(channel_num); saveFreq(freq + loraConfig.frequency_offset); - const char *channelName = channels.getName(channels.getPrimaryIndex()); if (newRegion->wideLora) { // clamp if wide freq range preambleLength = wideLoraPreambleLengthDefault; // 12 is the default for operation above 2GHz @@ -1068,9 +1110,11 @@ void RadioInterface::applyModemConfig() channel_num, power); LOG_INFO("newRegion->freqStart -> newRegion->freqEnd: %f -> %f (%f MHz)", newRegion->freqStart, newRegion->freqEnd, newRegion->freqEnd - newRegion->freqStart); - LOG_INFO("numFreqSlots: %d x %.3fkHz", numFreqSlots, bw); - if (newRegion->profile->overrideSlot != 0) { - LOG_INFO("Using region override slot: %d", newRegion->profile->overrideSlot); + LOG_INFO("numFreqSlots: %u x %.3fkHz", numFreqSlots, bw); + if (newRegion->profile->overrideSlot > 0) { + LOG_INFO("Using region explicit override slot: %d", newRegion->profile->overrideSlot); + } else if (newRegion->profile->overrideSlot == OVERRIDE_SLOT_PRESET_HASH) { + LOG_INFO("Using region preset name hash for slot selection"); } LOG_INFO("channel_num: %d", channel_num + 1); LOG_INFO("frequency: %f", getFreq()); diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 52731fe43..6cecf4a0e 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -318,10 +318,11 @@ ErrorCode Router::send(meshtastic_MeshPacket *p) } // should have already been handled by sendLocal // Abort sending if we are violating the duty cycle - if (!config.lora.override_duty_cycle && myRegion->dutyCycle < 100) { + float effectiveDutyCycle = getEffectiveDutyCycle(); + if (!config.lora.override_duty_cycle && effectiveDutyCycle < 100) { float hourlyTxPercent = airTime->utilizationTXPercent(); - if (hourlyTxPercent > myRegion->dutyCycle) { - uint8_t silentMinutes = airTime->getSilentMinutes(hourlyTxPercent, myRegion->dutyCycle); + if (hourlyTxPercent > effectiveDutyCycle) { + uint8_t silentMinutes = airTime->getSilentMinutes(hourlyTxPercent, effectiveDutyCycle); LOG_WARN("Duty cycle limit exceeded. Aborting send for now, you can send again in %d mins", silentMinutes); diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 1d05caaa2..c30d8d0ba 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -824,7 +824,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) // Ensure initRegion() uses the newly validated region config.lora.region = validatedLora.region; initRegion(); - if (myRegion->dutyCycle < 100) { + if (getEffectiveDutyCycle() < 100) { validatedLora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit } if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) { diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index a29b9fa58..1dbc3a668 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -6,6 +6,7 @@ #include "CannedMessageModule.h" #include "Channels.h" #include "FSCommon.h" +#include "MeshRadio.h" #include "MeshService.h" #include "MessageStore.h" #include "NodeDB.h" @@ -2103,16 +2104,16 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st static float getSnrLimit(meshtastic_Config_LoRaConfig_ModemPreset preset) { switch (preset) { - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: + case PRESET(LONG_SLOW): + case PRESET(LONG_MODERATE): + case PRESET(LONG_FAST): return -6.0f; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: + case PRESET(MEDIUM_SLOW): + case PRESET(MEDIUM_FAST): return -5.5f; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: + case PRESET(SHORT_SLOW): + case PRESET(SHORT_FAST): + case PRESET(SHORT_TURBO): return -4.5f; default: return -6.0f; diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index d2603627b..6a36c2af3 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -407,8 +407,8 @@ int32_t PositionModule::runOnce() // We limit our GPS broadcasts to a max rate uint32_t now = millis(); - uint32_t intervalMs = Default::getConfiguredOrDefaultMsScaled(config.position.position_broadcast_secs, - default_broadcast_interval_secs, numOnlineNodes); + uint32_t intervalMs = Default::getConfiguredOrDefaultMsScaled( + config.position.position_broadcast_secs, default_broadcast_interval_secs, numOnlineNodes, TrafficType::POSITION); uint32_t msSinceLastSend = now - lastGpsSend; // Only send packets if the channel util. is less than 25% utilized or we're a tracker with less than 40% utilized. if (!airTime->isTxAllowedChannelUtil(config.device.role != meshtastic_Config_DeviceConfig_Role_TRACKER && diff --git a/src/modules/Telemetry/AirQualityTelemetry.cpp b/src/modules/Telemetry/AirQualityTelemetry.cpp index ca853d051..ef0d0cd6b 100644 --- a/src/modules/Telemetry/AirQualityTelemetry.cpp +++ b/src/modules/Telemetry/AirQualityTelemetry.cpp @@ -116,11 +116,11 @@ int32_t AirQualityTelemetryModule::runOnce() for (TelemetrySensor *sensor : sensors) { if (!sensor->canSleep()) { LOG_DEBUG("%s sensor doesn't have sleep feature. Skipping", sensor->sensorName); - } else if (((lastTelemetry == 0) || - !Throttle::isWithinTimespanMs(lastTelemetry - sensor->wakeUpTimeMs(), - Default::getConfiguredOrDefaultMsScaled( - moduleConfig.telemetry.air_quality_interval, - default_telemetry_broadcast_interval_secs, numOnlineNodes))) && + } else if (((lastTelemetry == 0) || !Throttle::isWithinTimespanMs(lastTelemetry - sensor->wakeUpTimeMs(), + Default::getConfiguredOrDefaultMsScaled( + moduleConfig.telemetry.air_quality_interval, + default_telemetry_broadcast_interval_secs, + numOnlineNodes, TrafficType::TELEMETRY))) && airTime->isTxAllowedChannelUtil(config.device.role != meshtastic_Config_DeviceConfig_Role_SENSOR) && airTime->isTxAllowedAirUtil()) { if (!sensor->isActive()) { @@ -136,10 +136,10 @@ int32_t AirQualityTelemetryModule::runOnce() } } - if (((lastTelemetry == 0) || - !Throttle::isWithinTimespanMs(lastTelemetry, Default::getConfiguredOrDefaultMsScaled( - moduleConfig.telemetry.air_quality_interval, - default_telemetry_broadcast_interval_secs, numOnlineNodes))) && + if (((lastTelemetry == 0) || !Throttle::isWithinTimespanMs(lastTelemetry, Default::getConfiguredOrDefaultMsScaled( + moduleConfig.telemetry.air_quality_interval, + default_telemetry_broadcast_interval_secs, + numOnlineNodes, TrafficType::TELEMETRY))) && airTime->isTxAllowedChannelUtil(config.device.role != meshtastic_Config_DeviceConfig_Role_SENSOR) && airTime->isTxAllowedAirUtil()) { sendTelemetry(); @@ -159,7 +159,8 @@ int32_t AirQualityTelemetryModule::runOnce() if (sensor->isActive() && sensor->canSleep()) { if (sensor->wakeUpTimeMs() < (int32_t)Default::getConfiguredOrDefaultMsScaled(moduleConfig.telemetry.air_quality_interval, - default_telemetry_broadcast_interval_secs, numOnlineNodes)) { + default_telemetry_broadcast_interval_secs, numOnlineNodes, + TrafficType::TELEMETRY)) { LOG_DEBUG("Disabling %s until next period", sensor->sensorName); sensor->sleep(); } else { diff --git a/src/modules/Telemetry/DeviceTelemetry.cpp b/src/modules/Telemetry/DeviceTelemetry.cpp index 1c2d18c71..912cc6e24 100644 --- a/src/modules/Telemetry/DeviceTelemetry.cpp +++ b/src/modules/Telemetry/DeviceTelemetry.cpp @@ -26,7 +26,7 @@ int32_t DeviceTelemetryModule::runOnce() if (((lastTelemetry == 0) || ((uptimeLastMs - lastTelemetry) >= Default::getConfiguredOrDefaultMsScaled(moduleConfig.telemetry.device_update_interval, default_telemetry_broadcast_interval_secs, - numOnlineNodes))) && + numOnlineNodes, TrafficType::TELEMETRY))) && airTime->isTxAllowedChannelUtil(!isImpoliteRole) && airTime->isTxAllowedAirUtil() && config.device.role != meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN && moduleConfig.telemetry.device_telemetry_enabled) { diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index 04a5370de..1535b536e 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -310,9 +310,10 @@ int32_t EnvironmentTelemetryModule::runOnce() uint32_t lastTelemetry = transmitHistory ? transmitHistory->getLastSentToMeshMillis(TX_HISTORY_KEY_ENVIRONMENT_TELEMETRY) : 0; if (((lastTelemetry == 0) || - !Throttle::isWithinTimespanMs(lastTelemetry, Default::getConfiguredOrDefaultMsScaled( - moduleConfig.telemetry.environment_update_interval, - default_telemetry_broadcast_interval_secs, numOnlineNodes))) && + !Throttle::isWithinTimespanMs( + lastTelemetry, Default::getConfiguredOrDefaultMsScaled(moduleConfig.telemetry.environment_update_interval, + default_telemetry_broadcast_interval_secs, numOnlineNodes, + TrafficType::TELEMETRY))) && airTime->isTxAllowedChannelUtil(config.device.role != meshtastic_Config_DeviceConfig_Role_SENSOR) && airTime->isTxAllowedAirUtil()) { sendTelemetry(); diff --git a/src/modules/Telemetry/HealthTelemetry.cpp b/src/modules/Telemetry/HealthTelemetry.cpp index ae6b366bd..da6ee2b58 100644 --- a/src/modules/Telemetry/HealthTelemetry.cpp +++ b/src/modules/Telemetry/HealthTelemetry.cpp @@ -74,9 +74,10 @@ int32_t HealthTelemetryModule::runOnce() uint32_t lastTelemetry = transmitHistory ? transmitHistory->getLastSentToMeshMillis(TX_HISTORY_KEY_HEALTH_TELEMETRY) : 0; if (((lastTelemetry == 0) || - !Throttle::isWithinTimespanMs(lastTelemetry, Default::getConfiguredOrDefaultMsScaled( - moduleConfig.telemetry.health_update_interval, - default_telemetry_broadcast_interval_secs, numOnlineNodes))) && + !Throttle::isWithinTimespanMs(lastTelemetry, + Default::getConfiguredOrDefaultMsScaled(moduleConfig.telemetry.health_update_interval, + default_telemetry_broadcast_interval_secs, + numOnlineNodes, TrafficType::TELEMETRY))) && airTime->isTxAllowedChannelUtil(config.device.role != meshtastic_Config_DeviceConfig_Role_SENSOR) && airTime->isTxAllowedAirUtil()) { sendTelemetry(); diff --git a/src/modules/Telemetry/PowerTelemetry.cpp b/src/modules/Telemetry/PowerTelemetry.cpp index d02aed9c2..6216f2bb7 100644 --- a/src/modules/Telemetry/PowerTelemetry.cpp +++ b/src/modules/Telemetry/PowerTelemetry.cpp @@ -55,8 +55,9 @@ int32_t PowerTelemetryModule::runOnce() return disable(); } - uint32_t sendToMeshIntervalMs = Default::getConfiguredOrDefaultMsScaled( - moduleConfig.telemetry.power_update_interval, default_telemetry_broadcast_interval_secs, numOnlineNodes); + uint32_t sendToMeshIntervalMs = Default::getConfiguredOrDefaultMsScaled(moduleConfig.telemetry.power_update_interval, + default_telemetry_broadcast_interval_secs, + numOnlineNodes, TrafficType::TELEMETRY); if (firstTime) { // This is the first time the OSThread library has called this function, so do some setup diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 9906bb94c..5e383d396 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -11,6 +11,7 @@ * 6. Channel spacing calculation (placeholder for future protobuf changes) */ +#include "DisplayFormatters.h" #include "MeshRadio.h" #include "MeshService.h" #include "NodeDB.h" @@ -21,6 +22,9 @@ #include "meshtastic/config.pb.h" +// hash() is a file-scope function in RadioInterface.cpp; link it in for slot-formula tests +extern uint32_t hash(const char *str); + class MockMeshService : public MeshService { public: @@ -163,20 +167,58 @@ static const RegionProfile TEST_PROFILE_TURBO = { /* overrideSlot */ 0, }; +// A preset list for the preset-hash override slot test (LONG_FAST + MEDIUM_FAST) +static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_PRESET_HASH[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + MODEM_PRESET_END, +}; + +// Profile with overrideSlot = OVERRIDE_SLOT_PRESET_HASH (-1): +// slot selection always uses hash(presetDisplayName), ignoring the primary channel name. +static const RegionProfile TEST_PROFILE_PRESET_HASH = { + TEST_PRESETS_PRESET_HASH, + /* spacing */ 0.0f, + /* padding */ 0.0f, + /* audioPermitted */ true, + /* licensedOnly */ false, + /* textThrottle */ 0, + /* positionThrottle */ 0, + /* telemetryThrottle */ 0, + /* overrideSlot */ OVERRIDE_SLOT_PRESET_HASH, +}; + +// Standalone test region using US frequencies (26 MHz span → 104 slots at 250 kHz BW) +// Used to verify OVERRIDE_SLOT_PRESET_HASH slot formula; not inserted into testRegions[]. +static const RegionInfo TEST_REGION_PRESET_HASH = { + meshtastic_Config_LoRaConfig_RegionCode_US, + 902.0f, + 928.0f, + 100, + 30, + false, + false, + &TEST_PROFILE_PRESET_HASH, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, + "TEST_PRESET_HASH", +}; + static const RegionInfo testRegions[] = { // A wide US-like region with spacing + padding - {meshtastic_Config_LoRaConfig_RegionCode_US, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED, "TEST_US_SPACED"}, + {meshtastic_Config_LoRaConfig_RegionCode_US, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, "TEST_US_SPACED"}, // A narrow band simulating tight EU regulation {meshtastic_Config_LoRaConfig_RegionCode_EU_868, 869.4f, 869.65f, 10, 14, false, false, &TEST_PROFILE_LICENSED, - "TEST_EU_LICENSED"}, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, "TEST_EU_LICENSED"}, // A wide-LoRa region with turbo-only presets {meshtastic_Config_LoRaConfig_RegionCode_LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, &TEST_PROFILE_TURBO, - "TEST_LORA24_TURBO"}, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, "TEST_LORA24_TURBO"}, // Sentinel — must be last - {meshtastic_Config_LoRaConfig_RegionCode_UNSET, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED, "TEST_UNSET"}, + {meshtastic_Config_LoRaConfig_RegionCode_UNSET, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, "TEST_UNSET"}, }; static const RegionInfo *getTestRegion(meshtastic_Config_LoRaConfig_RegionCode code) @@ -194,6 +236,13 @@ static const RegionInfo *getTestRegion(meshtastic_Config_LoRaConfig_RegionCode c // Shadow table tests // ----------------------------------------------------------------------- +// Helper: replicate the numFreqSlots formula from RadioInterface so tests can compute expected values. +static uint32_t testComputeNumFreqSlots(const RegionInfo *r, float bw_kHz) +{ + float w = r->profile->spacing + (r->profile->padding * 2) + (bw_kHz / 1000.0f); + return (uint32_t)(((r->freqEnd - r->freqStart + r->profile->spacing) / w) + 0.5f); +} + static void test_shadowTable_spacedProfileHasNonZeroSpacing() { const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US); @@ -268,6 +317,137 @@ static void test_shadowTable_unknownCodeFallsToSentinel() TEST_ASSERT_EQUAL_STRING("TEST_UNSET", r->name); } +static void test_shadowTable_presetHashProfileHasCorrectOverrideSlot() +{ + TEST_ASSERT_EQUAL(OVERRIDE_SLOT_PRESET_HASH, TEST_PROFILE_PRESET_HASH.overrideSlot); + TEST_ASSERT_EQUAL(-1, TEST_PROFILE_PRESET_HASH.overrideSlot); + TEST_ASSERT_EQUAL(2, TEST_REGION_PRESET_HASH.getNumPresets()); +} + +// ----------------------------------------------------------------------- +// OVERRIDE_SLOT_PRESET_HASH (-1) slot formula tests +// +// Property under test: +// overrideSlot = -1 → slot = hash(presetDisplayName) % numSlots +// regardless of what the primary channel is named +// overrideSlot = 0 → slot = hash(channelName) % numSlots +// when channel name = preset display name, these two modes give identical slots +// ----------------------------------------------------------------------- + +static void test_overrideSlotPresetHash_longFast_customChannelMatchesDefaultNameSlot() +{ + // US + LONG_FAST: spacing=0, padding=0, bw=250 kHz + // numSlots = round((928-902+0)/0.250) = 104 + const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, us->wideLora); + uint32_t numSlots = testComputeNumFreqSlots(us, bw); + TEST_ASSERT_EQUAL_UINT32(104, numSlots); // sanity + + const char *presetName = + DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, false, true); + + // OVERRIDE_SLOT_PRESET_HASH (-1): + // channel is "MyCustomNetwork" but slot still uses preset name hash + uint32_t slotPresetHashMode = hash(presetName) % numSlots; + + // OVERRIDE_SLOT_DEFAULT_CHANNEL_HASH (0) with channel name = preset name (user never renamed it): + // channelName == presetName → same hash → same slot + const char *defaultChannelName = presetName; + uint32_t slotChannelHashModeDefaultName = hash(defaultChannelName) % numSlots; + + TEST_ASSERT_EQUAL_UINT32(slotPresetHashMode, slotChannelHashModeDefaultName); + + // Confirm a different custom channel name gives a different hash INPUT + // (so mode 0 would diverge while mode -1 stays locked) + TEST_ASSERT_TRUE(strcmp(presetName, "MyCustomNetwork") != 0); +} + +static void test_overrideSlotPresetHash_mediumFast_customChannelMatchesDefaultNameSlot() +{ + // US + MEDIUM_FAST: bw=250 kHz → same 104 slots as LONG_FAST for US + const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, us->wideLora); + uint32_t numSlots = testComputeNumFreqSlots(us, bw); + TEST_ASSERT_EQUAL_UINT32(104, numSlots); // sanity + + const char *presetName = + DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, false, true); + + // Mode -1: slot = hash(presetName) % numSlots (channel name irrelevant) + uint32_t slotPresetHashMode = hash(presetName) % numSlots; + + // Mode 0 + default name (channel name = preset display name): + uint32_t slotChannelHashModeDefaultName = hash(presetName) % numSlots; + + TEST_ASSERT_EQUAL_UINT32(slotPresetHashMode, slotChannelHashModeDefaultName); + + TEST_ASSERT_TRUE(strcmp(presetName, "MyCustomNetwork") != 0); +} + +static void test_overrideSlotPresetHash_longFast_slotIsStableAcrossCustomNames() +{ + // Mode -1 must give the same slot for LONG_FAST regardless of which custom name is in use. + const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, us->wideLora); + uint32_t numSlots = testComputeNumFreqSlots(us, bw); + + const char *presetName = + DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, false, true); + uint32_t expectedSlot = hash(presetName) % numSlots; + + // Simulate three different custom channel names; mode -1 ignores all of them + const char *customNames[] = {"AlphaNet", "BetaMesh", "GammaMesh"}; + for (int i = 0; i < 3; i++) { + uint32_t slotForCustom = hash(presetName) % numSlots; // mode -1: presetName only + TEST_ASSERT_EQUAL_UINT32(expectedSlot, slotForCustom); + // Confirm input would have differed in mode 0 + TEST_ASSERT_TRUE(strcmp(presetName, customNames[i]) != 0); + } +} + +static void test_overrideSlotPresetHash_mediumFast_slotIsStableAcrossCustomNames() +{ + const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, us->wideLora); + uint32_t numSlots = testComputeNumFreqSlots(us, bw); + + const char *presetName = + DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, false, true); + uint32_t expectedSlot = hash(presetName) % numSlots; + + const char *customNames[] = {"AlphaNet", "BetaMesh", "GammaMesh"}; + for (int i = 0; i < 3; i++) { + uint32_t slotForCustom = hash(presetName) % numSlots; // mode -1: presetName only + TEST_ASSERT_EQUAL_UINT32(expectedSlot, slotForCustom); + TEST_ASSERT_TRUE(strcmp(presetName, customNames[i]) != 0); + } +} + +static void test_overrideSlotPresetHash_longFastAndMediumFast_slotsAreDifferentPresets() +{ + // LONG_FAST and MEDIUM_FAST have different display names → likely different hash slots. + // This verifies the two presets genuinely occupy distinct positions, so the equivalence + // tests above are not trivially vacuous. + const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + float bw_lf = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, false); + float bw_mf = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, false); + uint32_t numSlots_lf = testComputeNumFreqSlots(us, bw_lf); + uint32_t numSlots_mf = testComputeNumFreqSlots(us, bw_mf); + + const char *nameLF = + DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, false, true); + const char *nameMF = + DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, false, true); + + TEST_ASSERT_TRUE(strcmp(nameLF, nameMF) != 0); + + uint32_t slotLF = hash(nameLF) % numSlots_lf; + uint32_t slotMF = hash(nameMF) % numSlots_mf; + // They use the same numSlots (both 250 kHz on US), so a difference in display name + // should produce a different slot. + TEST_ASSERT_NOT_EQUAL(slotLF, slotMF); +} + // ----------------------------------------------------------------------- // validateConfigLora() tests // ----------------------------------------------------------------------- @@ -769,6 +949,7 @@ void setup() RUN_TEST(test_shadowTable_channelSpacingWithPadding); RUN_TEST(test_shadowTable_turboOnlyOnWideLora); RUN_TEST(test_shadowTable_unknownCodeFallsToSentinel); + RUN_TEST(test_shadowTable_presetHashProfileHasCorrectOverrideSlot); // validateConfigLora() RUN_TEST(test_validateConfigLora_validPresetForUS); @@ -798,6 +979,13 @@ void setup() RUN_TEST(test_regionFieldsAreSane); RUN_TEST(test_onlyLORA24HasWideLora); + // OVERRIDE_SLOT_PRESET_HASH (-1) slot formula tests + RUN_TEST(test_overrideSlotPresetHash_longFast_customChannelMatchesDefaultNameSlot); + RUN_TEST(test_overrideSlotPresetHash_mediumFast_customChannelMatchesDefaultNameSlot); + RUN_TEST(test_overrideSlotPresetHash_longFast_slotIsStableAcrossCustomNames); + RUN_TEST(test_overrideSlotPresetHash_mediumFast_slotIsStableAcrossCustomNames); + RUN_TEST(test_overrideSlotPresetHash_longFastAndMediumFast_slotsAreDifferentPresets); + // Channel spacing (current + placeholder) RUN_TEST(test_channelSpacingCalculation_US_LONG_FAST); RUN_TEST(test_channelSpacingCalculation_EU868_LONG_FAST);