diff --git a/src/DisplayFormatters.cpp b/src/DisplayFormatters.cpp index fdcf840dc28..13aded6a6ae 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 a7736d66711..0e0d72e20e9 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 386a4c077de..10fdeb06f61 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); } -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{}; +// Maximum presets any region can have + 1 for Back +static constexpr int MAX_PRESET_OPTIONS = 16; - auto bannerOptions = - createStaticBannerOptions("Radio Preset", presetOptions, presetLabels, [](const RadioPresetOption &option, int) -> void { - if (option.action == OptionsAction::Back) { - menuHandler::menuQueue = menuHandler::LoraMenu; - screen->runNow(); - return; - } +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]); + } + } - if (!option.hasValue) { - return; - } + int initialSelection = 0; + for (int i = 1; i < count; ++i) { + if (optionsEnumArray[i] == static_cast(config.lora.modem_preset)) { + initialSelection = i; + break; + } + } - 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); - }); + 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; +} - screen->showOverlayBanner(bannerOptions); +void menuHandler::radioPresetPicker() +{ + screen->showOverlayBanner(buildRegionPresetBanner()); } void menuHandler::twelveHourPicker() diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index cfde101247f..408e5bc262e 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 e1f004d389f..fc1aef02a0b 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 79ac1e701b2..b70853151a0 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 7a2d9e410d2..229e8fcab23 100644 --- a/src/mesh/Default.cpp +++ b/src/mesh/Default.cpp @@ -60,6 +60,24 @@ 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; + + return baseMs * static_cast(throttle); +} + 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 59425042ebf..b1ebf5f2f06 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/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index 1d1616ed673..0025631bd8c 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -166,6 +166,11 @@ template bool LR11x0Interface::init() return res == RADIOLIB_ERR_NONE; } +template int16_t LR11x0Interface::applyCodingRate(uint8_t codingRate) +{ + return lora.setCodingRate(codingRate, codingRate != 7); +} + template bool LR11x0Interface::reconfigure() { RadioLibInterface::reconfigure(); diff --git a/src/mesh/LR11x0Interface.h b/src/mesh/LR11x0Interface.h index 1a6b925206b..80a80aa7f0c 100644 --- a/src/mesh/LR11x0Interface.h +++ b/src/mesh/LR11x0Interface.h @@ -70,6 +70,8 @@ template class LR11x0Interface : public RadioLibInterface virtual void setStandby() override; + int16_t applyCodingRate(uint8_t codingRate) override; + uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } }; #endif \ No newline at end of file diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index fe4788bff0a..e2c053a8bed 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/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index e8613d45729..87c084df54a 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -9,12 +9,27 @@ #endif #include "NodeDB.h" +namespace +{ + +// Returns how many retransmission attempts have been made so far (1-indexed). +// numRetransmissions is decremented AFTER each send, so on the first retransmission +// it still equals initialNumRetransmissions, giving attempt = 1. +uint8_t getRetransmissionAttempt(const PendingPacket &pending) +{ + return (pending.initialNumRetransmissions - pending.numRetransmissions) + 1; +} + +} // namespace + NextHopRouter::NextHopRouter() {} PendingPacket::PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions) { packet = p; - this->numRetransmissions = numRetransmissions - 1; // We subtract one, because we assume the user just did the first send + // Subtract one because the first send has already happened before this record is created. + initialNumRetransmissions = numRetransmissions > 0 ? numRetransmissions - 1 : 0; + this->numRetransmissions = initialNumRetransmissions; } /** @@ -302,12 +317,18 @@ int32_t NextHopRouter::doRetransmissions() p.packet->id); sendAckNak(meshtastic_Routing_Error_MAX_RETRANSMIT, getFrom(p.packet), p.packet->id, p.packet->channel); } + txRetransmitFailed++; // Note: we don't stop retransmission here, instead the Nak packet gets processed in sniffReceived stopRetransmission(it->first); stillValid = false; // just deleted it } else { - LOG_DEBUG("Sending retransmission fr=0x%x,to=0x%x,id=0x%x, tries left=%d", p.packet->from, p.packet->to, - p.packet->id, p.numRetransmissions); + auto *retransmission = packetPool.allocCopy(*p.packet); + uint8_t desiredCodingRate = + computeDesiredRetransmissionCodingRate(iface->getCodingRate(), getRetransmissionAttempt(p)); + LOG_DEBUG("Sending retransmission fr=0x%x,to=0x%x,id=0x%x, tries left=%d, cr=%u", p.packet->from, p.packet->to, + p.packet->id, p.numRetransmissions, desiredCodingRate); + iface->setTransmitCodingRateOverride(getFrom(retransmission), retransmission->id, desiredCodingRate); + ErrorCode sendResult = ERRNO_UNKNOWN; if (!isBroadcast(p.packet->to)) { if (p.numRetransmissions == 1) { @@ -319,14 +340,19 @@ int32_t NextHopRouter::doRetransmissions() LOG_INFO("Resetting next hop for packet with dest 0x%x\n", p.packet->to); sentTo->next_hop = NO_NEXT_HOP_PREFERENCE; } - FloodingRouter::send(packetPool.allocCopy(*p.packet)); + sendResult = FloodingRouter::send(retransmission); } else { - NextHopRouter::send(packetPool.allocCopy(*p.packet)); + sendResult = NextHopRouter::send(retransmission); } } else { // Note: we call the superclass version because we don't want to have our version of send() add a new // retransmission record - FloodingRouter::send(packetPool.allocCopy(*p.packet)); + sendResult = FloodingRouter::send(retransmission); + } + + if (sendResult != ERRNO_OK) { + LOG_DEBUG("Send failed (err=%d), discarding CR override for id=0x%x", sendResult, p.packet->id); + iface->clearTransmitCodingRateOverride(getFrom(p.packet), p.packet->id); } // Queue again diff --git a/src/mesh/NextHopRouter.h b/src/mesh/NextHopRouter.h index 42ef13cd9a7..9d14225c0c9 100644 --- a/src/mesh/NextHopRouter.h +++ b/src/mesh/NextHopRouter.h @@ -4,6 +4,16 @@ #include #include +static inline uint8_t computeDesiredRetransmissionCodingRate(uint8_t baseCodingRate, uint8_t retransmissionAttempt) +{ + if (retransmissionAttempt >= 2) { + return 8; + } + + uint16_t desiredCodingRate = baseCodingRate + 1; + return desiredCodingRate > 8 ? 8 : desiredCodingRate; +} + /** * An identifier for a globally unique message - a pair of the sending nodenum and the packet id assigned * to that message @@ -39,6 +49,9 @@ struct PendingPacket { /** Starts at NUM_RETRANSMISSIONS -1 and counts down. Once zero it will be removed from the list */ uint8_t numRetransmissions = 0; + /** The initial retransmission budget after the first send has already happened. */ + uint8_t initialNumRetransmissions = 0; + PendingPacket() {} explicit PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions); }; diff --git a/src/mesh/RF95Interface.cpp b/src/mesh/RF95Interface.cpp index 43149ef8b40..a0f955c9acf 100644 --- a/src/mesh/RF95Interface.cpp +++ b/src/mesh/RF95Interface.cpp @@ -196,6 +196,11 @@ bool RF95Interface::init() return res == RADIOLIB_ERR_NONE; } +int16_t RF95Interface::applyCodingRate(uint8_t codingRate) +{ + return lora->setCodingRate(codingRate); +} + void RF95Interface::disableInterrupt() { lora->clearDio0Action(); diff --git a/src/mesh/RF95Interface.h b/src/mesh/RF95Interface.h index ffd8ae0082b..0d563cee4e0 100644 --- a/src/mesh/RF95Interface.h +++ b/src/mesh/RF95Interface.h @@ -65,6 +65,8 @@ class RF95Interface : public RadioLibInterface */ virtual void configHardwareForSend() override; + int16_t applyCodingRate(uint8_t codingRate) override; + uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(*lora, pl, received); } private: diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index f4f25f80c9b..940e1eea3f3 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.375F, 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/RadioInterface.h b/src/mesh/RadioInterface.h index a1c692e24b2..616a418ada0 100644 --- a/src/mesh/RadioInterface.h +++ b/src/mesh/RadioInterface.h @@ -174,6 +174,15 @@ class RadioInterface /** Attempt to find a packet in the TxQueue. Returns true if the packet was found. */ virtual bool findInTxQueue(NodeNum from, PacketId id) { return false; } + /** Return the currently configured LoRa coding rate. */ + [[nodiscard]] uint8_t getCodingRate() const { return cr; } + + /** Best-effort per-packet transmit coding-rate override. Base implementation is a no-op. */ + virtual void setTransmitCodingRateOverride(NodeNum from, PacketId id, uint8_t codingRate) {} + + /** Clear any queued per-packet transmit coding-rate override. Base implementation is a no-op. */ + virtual void clearTransmitCodingRateOverride(NodeNum from, PacketId id) {} + // methods from radiohead /// Initialise the Driver transport hardware and software. diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index de468cf9793..71d1bffb023 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -157,12 +157,14 @@ ErrorCode RadioLibInterface::send(meshtastic_MeshPacket *p) if (config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { if (disabled || !config.lora.tx_enabled) { LOG_WARN("send - !config.lora.tx_enabled"); + clearTransmitCodingRateOverride(getFrom(p), p->id); packetPool.release(p); return ERRNO_DISABLED; } } else { LOG_WARN("send - lora tx disabled: Region unset"); + clearTransmitCodingRateOverride(getFrom(p), p->id); packetPool.release(p); return ERRNO_DISABLED; } @@ -171,6 +173,7 @@ ErrorCode RadioLibInterface::send(meshtastic_MeshPacket *p) if (disabled || !config.lora.tx_enabled) { LOG_WARN("send - !config.lora.tx_enabled"); + clearTransmitCodingRateOverride(getFrom(p), p->id); packetPool.release(p); return ERRNO_DISABLED; } @@ -179,6 +182,7 @@ ErrorCode RadioLibInterface::send(meshtastic_MeshPacket *p) if (p->to == NODENUM_BROADCAST_NO_LORA) { LOG_DEBUG("Drop no-LoRa pkt"); + clearTransmitCodingRateOverride(getFrom(p), p->id); return ERRNO_SHOULD_RELEASE; } @@ -195,6 +199,7 @@ ErrorCode RadioLibInterface::send(meshtastic_MeshPacket *p) } if (res != ERRNO_OK) { // we weren't able to queue it, so we must drop it to prevent leaks + clearTransmitCodingRateOverride(getFrom(p), p->id); packetPool.release(p); return res; } @@ -242,8 +247,10 @@ bool RadioLibInterface::isSending() bool RadioLibInterface::cancelSending(NodeNum from, PacketId id) { auto p = txQueue.remove(from, id); - if (p) + if (p) { + clearTransmitCodingRateOverride(getFrom(p), p->id); packetPool.release(p); // free the packet we just removed + } bool result = (p != NULL); LOG_DEBUG("cancelSending id=0x%x, removed=%d", id, result); @@ -408,12 +415,74 @@ bool RadioLibInterface::removePendingTXPacket(NodeNum from, PacketId id, uint32_ meshtastic_MeshPacket *p = txQueue.remove(from, id, true, true, hop_limit_lt); if (p) { LOG_DEBUG("Dropping pending-TX packet 0x%08x with hop limit %d", p->id, p->hop_limit); + clearTransmitCodingRateOverride(getFrom(p), p->id); packetPool.release(p); return true; } return false; } +void RadioLibInterface::setTransmitCodingRateOverride(NodeNum from, PacketId id, uint8_t codingRate) +{ + txCodingRateOverrides[TxPacketId{from, id}] = codingRate; +} + +void RadioLibInterface::clearTransmitCodingRateOverride(NodeNum from, PacketId id) +{ + txCodingRateOverrides.erase(TxPacketId{from, id}); +} + +std::optional RadioLibInterface::takeTransmitCodingRateOverride(NodeNum from, PacketId id) +{ + auto it = txCodingRateOverrides.find(TxPacketId{from, id}); + if (it == txCodingRateOverrides.end()) { + return std::nullopt; + } + + uint8_t codingRate = it->second; + txCodingRateOverrides.erase(it); + return codingRate; +} + +bool RadioLibInterface::applyTemporaryCodingRateOverride(const meshtastic_MeshPacket *packet) +{ + auto overrideCodingRate = takeTransmitCodingRateOverride(getFrom(packet), packet->id); + if (!overrideCodingRate || *overrideCodingRate == cr) { + return true; + } + + setStandby(); + int err = applyCodingRate(*overrideCodingRate); + if (err != RADIOLIB_ERR_NONE) { + LOG_WARN("Temporary coding rate override failed for packet 0x%x, error=%d", packet->id, err); + int restoreErr = applyCodingRate(cr); + if (restoreErr != RADIOLIB_ERR_NONE) { + LOG_WARN("Restoring base coding rate %u failed, error=%d", cr, restoreErr); + } + return false; + } + + activeTxCodingRateOverride = *overrideCodingRate; + LOG_DEBUG("Using temporary coding rate %u for packet 0x%x", *overrideCodingRate, packet->id); + return true; +} + +void RadioLibInterface::restoreTemporaryCodingRateOverride() +{ + if (!activeTxCodingRateOverride) { + return; + } + + int err = applyCodingRate(cr); + if (err != RADIOLIB_ERR_NONE) { + LOG_WARN("Restoring base coding rate %u failed, error=%d", cr, err); + } else { + LOG_DEBUG("Restored coding rate to %u after retransmission", cr); + } + + activeTxCodingRateOverride.reset(); +} + /** * Remove a packet that is eligible for replacement from the TX queue */ @@ -436,8 +505,13 @@ void RadioLibInterface::completeSending() sendingPacket = NULL; if (p) { - // Packet has been sent, count it toward our TX airtime utilization. + // Compute airtime before restoring the base coding rate so that getPacketTime + // queries the hardware with the CR that was actually used for this transmission + // (which may have been escalated for a retransmission). uint32_t xmitMsec = getPacketTime(p); + restoreTemporaryCodingRateOverride(); + + // Packet has been sent, count it toward our TX airtime utilization. airTime->logAirtime(TX_LOG, xmitMsec); txGood++; @@ -447,6 +521,8 @@ void RadioLibInterface::completeSending() // We are done sending that packet, release it packetPool.release(p); + } else { + restoreTemporaryCodingRateOverride(); } } @@ -589,9 +665,11 @@ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp) channel scan and actual transmit as low as possible to avoid collisions. */ if (disabled || !config.lora.tx_enabled) { LOG_WARN("Drop Tx packet because LoRa Tx disabled"); + clearTransmitCodingRateOverride(getFrom(txp), txp->id); packetPool.release(txp); return false; } else { + applyTemporaryCodingRateOverride(txp); configHardwareForSend(); // must be after setStandby size_t numbytes = beginSending(txp); diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h index 0740561f9b2..3a9338d8e32 100644 --- a/src/mesh/RadioLibInterface.h +++ b/src/mesh/RadioLibInterface.h @@ -5,7 +5,9 @@ #include "concurrency/NotifiedWorkerThread.h" #include +#include #include +#include // ESP32 has special rules about ISR code #ifdef ARDUINO_ARCH_ESP32 @@ -52,6 +54,22 @@ class STM32WLx_ModuleWrapper : public STM32WLx_Module class RadioLibInterface : public RadioInterface, protected concurrency::NotifiedWorkerThread { + struct TxPacketId { + NodeNum from; + PacketId id; + + bool operator==(const TxPacketId &other) const { return from == other.from && id == other.id; } + }; + + class TxPacketIdHash + { + public: + size_t operator()(const TxPacketId &packet) const + { + return (std::hash()(packet.from)) ^ (std::hash()(packet.id)); + } + }; + /// Used as our notification from the ISR enum PendingISR { ISR_NONE = 0, ISR_RX, ISR_TX, TRANSMIT_DELAY_COMPLETED }; @@ -61,6 +79,8 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified static void isrTxLevel0(), isrLevel0Common(PendingISR code); MeshPacketQueue txQueue = MeshPacketQueue(MAX_TX_QUEUE); + std::unordered_map txCodingRateOverrides; + std::optional activeTxCodingRateOverride; protected: ModemType_t modemType = RADIOLIB_MODEM_LORA; @@ -179,6 +199,13 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified /** Attempt to find a packet in the TxQueue. Returns true if the packet was found. */ virtual bool findInTxQueue(NodeNum from, PacketId id) override; + void setTransmitCodingRateOverride(NodeNum from, PacketId id, uint8_t codingRate) override; + void clearTransmitCodingRateOverride(NodeNum from, PacketId id) override; + +#ifdef UNIT_TEST + friend class TestRadioLibInterface; +#endif + /** * Request randomness sourced from the LoRa modem, if supported by the active RadioLib interface. * @return true if len bytes were produced, false otherwise. @@ -209,6 +236,10 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified virtual void onNotify(uint32_t notification) override; + std::optional takeTransmitCodingRateOverride(NodeNum from, PacketId id); + bool applyTemporaryCodingRateOverride(const meshtastic_MeshPacket *packet); + void restoreTemporaryCodingRateOverride(); + /** start an immediate transmit * This method is virtual so subclasses can hook as needed, subclasses should not call directly * @return true if packet was sent @@ -220,6 +251,8 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified protected: uint32_t activeReceiveStart = 0; + virtual int16_t applyCodingRate(uint8_t codingRate) = 0; + bool receiveDetected(uint16_t irq, unsigned long syncWordHeaderValidFlag, unsigned long preambleDetectedFlag); /** Do any hardware setup needed on entry into send configuration for the radio. diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 980ceb6df6d..1b3a1b9b552 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -317,10 +317,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/mesh/Router.h b/src/mesh/Router.h index bd418869358..b9327c1f3f5 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -92,6 +92,10 @@ class Router : protected concurrency::OSThread, protected PacketHistory before us */ uint32_t rxDupe = 0, txRelayCanceled = 0; + /** Count of retransmission chains that exhausted all attempts without an ACK. + * TODO: map to a LocalStats proto field when the telemetry proto gains a suitable field. */ + uint32_t txRetransmitFailed = 0; + protected: friend class RoutingModule; diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 3513bbba3f3..b0bac7f048a 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -206,6 +206,11 @@ template bool SX126xInterface::init() return res == RADIOLIB_ERR_NONE; } +template int16_t SX126xInterface::applyCodingRate(uint8_t codingRate) +{ + return lora.setCodingRate(codingRate); +} + template bool SX126xInterface::reconfigure() { RadioLibInterface::reconfigure(); diff --git a/src/mesh/SX126xInterface.h b/src/mesh/SX126xInterface.h index 67625e1154a..cdae572f2c8 100644 --- a/src/mesh/SX126xInterface.h +++ b/src/mesh/SX126xInterface.h @@ -74,6 +74,8 @@ template class SX126xInterface : public RadioLibInterface virtual void setStandby() override; + int16_t applyCodingRate(uint8_t codingRate) override; + uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } private: diff --git a/src/mesh/SX128xInterface.cpp b/src/mesh/SX128xInterface.cpp index 64d71921a58..a600cd4323f 100644 --- a/src/mesh/SX128xInterface.cpp +++ b/src/mesh/SX128xInterface.cpp @@ -110,6 +110,11 @@ template bool SX128xInterface::init() return res == RADIOLIB_ERR_NONE; } +template int16_t SX128xInterface::applyCodingRate(uint8_t codingRate) +{ + return lora.setCodingRate(codingRate, codingRate != 7); +} + template bool SX128xInterface::reconfigure() { RadioLibInterface::reconfigure(); diff --git a/src/mesh/SX128xInterface.h b/src/mesh/SX128xInterface.h index acdcbbb27c8..92101eb33a2 100644 --- a/src/mesh/SX128xInterface.h +++ b/src/mesh/SX128xInterface.h @@ -68,5 +68,7 @@ template class SX128xInterface : public RadioLibInterface virtual void setStandby() override; + int16_t applyCodingRate(uint8_t codingRate) override; + uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } }; diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index ba1cfdb97e0..232beaa66a9 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -823,7 +823,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 a29b9fa5838..1dbc3a6684e 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 c13904c311b..6b0f8eaae00 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -427,8 +427,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 ca853d0510e..ef0d0cd6bc0 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 1c2d18c717c..912cc6e24e5 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 04a5370de4d..1535b536eb4 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 ae6b366bda3..da6ee2b58ab 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 d02aed9c2d7..6216f2bb74c 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 9906bb94c5c..5e383d396ef 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); diff --git a/test/test_retransmission_coding_rate/TestRadioLibInterface.h b/test/test_retransmission_coding_rate/TestRadioLibInterface.h new file mode 100644 index 00000000000..dbcc94dc203 --- /dev/null +++ b/test/test_retransmission_coding_rate/TestRadioLibInterface.h @@ -0,0 +1,82 @@ +#pragma once + +/** + * Minimal RadioLibInterface test double for airtime-ordering tests. + * + * applyCodingRate() records the last CR "written to hardware" so + * getPacketTime() can read it back and report which CR was in effect. + * This lets tests verify that completeSending() calls getPacketTime() + * BEFORE restoring the base CR rather than after. + * + * Protected members are exposed through thin wrappers so test functions + * can drive the object without subclassing it themselves. + */ + +#include "RadioLibInterface.h" +#include "error.h" + +class TestRadioLibInterface : public RadioLibInterface +{ + public: + /** CR last passed to applyCodingRate() — represents the hardware state. */ + uint8_t lastAppliedCr = 0; + + /** CR that was in effect when getPacketTime() was most recently called. */ + uint8_t crSeenByGetPacketTime = 0; + + /** + * Construct without real hardware. + * Null HAL and RADIOLIB_NC pins are safe as long as no SPI / IRQ + * operations are triggered — completeSending() performs neither. + */ + TestRadioLibInterface() : RadioLibInterface(nullptr, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC, RADIOLIB_NC, nullptr) {} + + // ----------------------------------------------------------------------- + // Required pure-virtual overrides + // ----------------------------------------------------------------------- + + /** Record the CR so getPacketTime() can observe it. */ + int16_t applyCodingRate(uint8_t codingRate) override + { + lastAppliedCr = codingRate; + return RADIOLIB_ERR_NONE; + } + + /** + * Encode the current hardware CR as the return value so tests can assert + * on how long an airtime was computed. Also snapshots lastAppliedCr into + * crSeenByGetPacketTime to verify ordering relative to the CR restore. + */ + uint32_t getPacketTime(uint32_t /*pl*/, bool /*received*/) override + { + crSeenByGetPacketTime = lastAppliedCr; + return static_cast(lastAppliedCr) * 1000U; + } + + bool isChannelActive() override { return false; } + bool isActivelyReceiving() override { return false; } + void addReceiveMetadata(meshtastic_MeshPacket *) override {} + void disableInterrupt() override {} + void enableInterrupt(void (*)()) override {} + ErrorCode send(meshtastic_MeshPacket *) override { return ERRNO_OK; } + + // ----------------------------------------------------------------------- + // Protected-member wrappers for use by test functions + // ----------------------------------------------------------------------- + + /** Set the base (configured) coding rate as applyModemConfig() would. */ + void setBaseCr(uint8_t c) { cr = c; } + + /** Inject a packet as if startSend() had assigned it to sendingPacket. */ + void setActivePacket(meshtastic_MeshPacket *p) { sendingPacket = p; } + + /** Invoke completeSending() (protected) from a test function. */ + void triggerCompleteSending() { completeSending(); } + + /** + * Simulate the hardware state after applyTemporaryCodingRateOverride() ran + * (the override CR is active on the radio but not yet restored). + * Accessible because RadioLibInterface declares TestRadioLibInterface a friend. + */ + void setActiveTxCodingRateOverrideForTest(std::optional v) { activeTxCodingRateOverride = v; } +}; diff --git a/test/test_retransmission_coding_rate/test_main.cpp b/test/test_retransmission_coding_rate/test_main.cpp new file mode 100644 index 00000000000..e3b594e8d5d --- /dev/null +++ b/test/test_retransmission_coding_rate/test_main.cpp @@ -0,0 +1,384 @@ +/** + * Tests for retransmission coding-rate selection and airtime-calculation + * ordering in RadioLibInterface::completeSending(). + * + * Coverage: + * 1. computeDesiredRetransmissionCodingRate() — the CR escalation policy. + * 2. completeSending() CR ordering — getPacketTime() must query the hardware + * BEFORE restoreTemporaryCodingRateOverride() resets the radio. This + * regression was fixed by reordering the two calls. + * 3. completeSending() counter semantics — txGood is incremented for every + * completed send; txRelay is incremented only when the packet originated + * from a remote node (i.e. we were relaying it). + * 4. completeSending() null-packet guard — even when sendingPacket is null + * (spurious interrupt), an active CR override must still be cleared so + * the radio is not left stuck at the override CR. + */ + +#include "MeshTypes.h" +#include "NextHopRouter.h" +#include "NodeDB.h" +#include "TestRadioLibInterface.h" // test double + wrappers +#include "TestUtil.h" +#include "airtime.h" +#include + +// --------------------------------------------------------------------------- +// Node address constants +// --------------------------------------------------------------------------- + +static constexpr NodeNum kLocalNodeNum = 0xAAAAAAAA; +static constexpr NodeNum kRemoteNodeNum = 0xBBBBBBBB; + +// --------------------------------------------------------------------------- +// Minimal NodeDB stub +// +// NodeDB is not abstract; subclassing gives us a concrete object whose +// getNodeNum() returns myNodeInfo.my_node_num, which we set in setUp(). +// The base constructor calls loadFromDisk(), which is a no-op (returns with +// an empty store) in the native test environment. +// --------------------------------------------------------------------------- + +class MinimalMockNodeDB : public NodeDB +{ +}; + +// --------------------------------------------------------------------------- +// Test globals +// --------------------------------------------------------------------------- + +static TestRadioLibInterface *testIface = nullptr; +static AirTime *savedAirTime = nullptr; +static AirTime *testAirTime = nullptr; +static MinimalMockNodeDB *mockNodeDB = nullptr; + +// --------------------------------------------------------------------------- +// Packet factory helper +// --------------------------------------------------------------------------- + +/** + * Allocate a zeroed MeshPacket from the real pool with the given from/to + * addresses and a small encrypted payload. completeSending() releases it + * back to the pool; callers must NOT release it themselves. + */ +static meshtastic_MeshPacket *allocPacket(NodeNum from, NodeNum to) +{ + meshtastic_MeshPacket *p = packetPool.allocZeroed(); + p->from = from; + p->to = to; + p->which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p->encrypted.size = 10; + return p; +} + +// --------------------------------------------------------------------------- +// Unity lifecycle +// --------------------------------------------------------------------------- + +void setUp(void) +{ + // AirTime::logAirtime() must not dereference a null pointer. + savedAirTime = airTime; + testAirTime = new AirTime(); + airTime = testAirTime; + + // Give isFromUs() a concrete local node number to compare against. + myNodeInfo.my_node_num = kLocalNodeNum; + nodeDB = mockNodeDB; + + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + + testIface = new TestRadioLibInterface(); +} + +void tearDown(void) +{ + delete testIface; + testIface = nullptr; + + nodeDB = nullptr; + + airTime = savedAirTime; + delete testAirTime; + testAirTime = nullptr; +} + +// --------------------------------------------------------------------------- +// Section 1 — computeDesiredRetransmissionCodingRate() policy +// --------------------------------------------------------------------------- + +static void test_base5_progression() +{ + TEST_ASSERT_EQUAL_UINT8(6, computeDesiredRetransmissionCodingRate(5, 1)); + TEST_ASSERT_EQUAL_UINT8(8, computeDesiredRetransmissionCodingRate(5, 2)); +} + +static void test_base6_progression() +{ + TEST_ASSERT_EQUAL_UINT8(7, computeDesiredRetransmissionCodingRate(6, 1)); + TEST_ASSERT_EQUAL_UINT8(8, computeDesiredRetransmissionCodingRate(6, 2)); +} + +static void test_base7_or_higher_progression() +{ + TEST_ASSERT_EQUAL_UINT8(8, computeDesiredRetransmissionCodingRate(7, 1)); + TEST_ASSERT_EQUAL_UINT8(8, computeDesiredRetransmissionCodingRate(7, 2)); + TEST_ASSERT_EQUAL_UINT8(8, computeDesiredRetransmissionCodingRate(8, 1)); + TEST_ASSERT_EQUAL_UINT8(8, computeDesiredRetransmissionCodingRate(8, 2)); +} + +static void test_second_or_later_retransmission_forces_eight() +{ + TEST_ASSERT_EQUAL_UINT8(8, computeDesiredRetransmissionCodingRate(5, 3)); + TEST_ASSERT_EQUAL_UINT8(8, computeDesiredRetransmissionCodingRate(6, 4)); +} + +// --------------------------------------------------------------------------- +// Section 2 — completeSending() CR ordering (regression) +// --------------------------------------------------------------------------- + +/** + * Own-originating packet with an active CR override (simulating the second or + * later retransmission of a packet). getPacketTime() must see the OVERRIDE + * CR — not the base CR that restoreTemporaryCodingRateOverride() restores + * afterward. + * + * Regression: the old code called restoreTemporaryCodingRateOverride() first, + * so getPacketTime() always saw the base CR. + */ +static void test_completeSending_airtimeUsesOverrideCr() +{ + const uint8_t baseCr = 5; + const uint8_t overrideCr = 7; + + testIface->setBaseCr(baseCr); + testIface->lastAppliedCr = overrideCr; + testIface->setActiveTxCodingRateOverrideForTest(overrideCr); + + // from == 0 ⟹ isFromUs() short-circuits to true (own-node packet). + meshtastic_MeshPacket *p = allocPacket(0, kRemoteNodeNum); + testIface->setActivePacket(p); + testIface->triggerCompleteSending(); + + TEST_ASSERT_EQUAL_UINT8(overrideCr, testIface->crSeenByGetPacketTime); + TEST_ASSERT_EQUAL_UINT8(baseCr, testIface->lastAppliedCr); + TEST_ASSERT_EQUAL_UINT32(1, testIface->txGood); + TEST_ASSERT_EQUAL_UINT32(0, testIface->txRelay); +} + +/** + * Own-originating packet with NO active override (normal, non-retransmission + * send). getPacketTime() must see the base CR and no restore call is made. + */ +static void test_completeSending_noOverride_seesBaseCr() +{ + const uint8_t baseCr = 5; + + testIface->setBaseCr(baseCr); + testIface->lastAppliedCr = baseCr; + + meshtastic_MeshPacket *p = allocPacket(0, kRemoteNodeNum); + testIface->setActivePacket(p); + testIface->triggerCompleteSending(); + + TEST_ASSERT_EQUAL_UINT8(baseCr, testIface->crSeenByGetPacketTime); + TEST_ASSERT_EQUAL_UINT8(baseCr, testIface->lastAppliedCr); + TEST_ASSERT_EQUAL_UINT32(1, testIface->txGood); + TEST_ASSERT_EQUAL_UINT32(0, testIface->txRelay); +} + +/** + * The CR ordering regression must also hold for relay (rebroadcast) packets, + * not only for packets originated by this node. getPacketTime() must observe + * the override CR even though the packet's `from` belongs to a remote node. + */ +static void test_completeSending_relayedPacket_withOverride_usesOverrideCr() +{ + const uint8_t baseCr = 5; + const uint8_t overrideCr = 8; + + testIface->setBaseCr(baseCr); + testIface->lastAppliedCr = overrideCr; + testIface->setActiveTxCodingRateOverrideForTest(overrideCr); + + // kRemoteNodeNum ≠ kLocalNodeNum ⟹ isFromUs() returns false (relay). + meshtastic_MeshPacket *p = allocPacket(kRemoteNodeNum, NODENUM_BROADCAST); + testIface->setActivePacket(p); + testIface->triggerCompleteSending(); + + TEST_ASSERT_EQUAL_UINT8(overrideCr, testIface->crSeenByGetPacketTime); + TEST_ASSERT_EQUAL_UINT8(baseCr, testIface->lastAppliedCr); + TEST_ASSERT_EQUAL_UINT32(1, testIface->txRelay); +} + +// --------------------------------------------------------------------------- +// Section 3 — completeSending() counter semantics per send scenario +// --------------------------------------------------------------------------- + +/** + * DM originated by this node (from == 0 ≡ "phone app source", unicast to a + * peer). txGood must be incremented; txRelay must NOT be. + */ +static void test_completeSending_ownDm_countsTxGoodOnly() +{ + const uint8_t baseCr = 5; + testIface->setBaseCr(baseCr); + testIface->lastAppliedCr = baseCr; + + meshtastic_MeshPacket *p = allocPacket(0, kRemoteNodeNum); + testIface->setActivePacket(p); + testIface->triggerCompleteSending(); + + TEST_ASSERT_EQUAL_UINT32(1, testIface->txGood); + TEST_ASSERT_EQUAL_UINT32(0, testIface->txRelay); +} + +/** + * Broadcast originated by this node. The to-address (BROADCAST vs unicast) + * is not inspected by completeSending(); only the from-address matters for + * the relay counter. + */ +static void test_completeSending_ownBroadcast_countsTxGoodOnly() +{ + const uint8_t baseCr = 5; + testIface->setBaseCr(baseCr); + testIface->lastAppliedCr = baseCr; + + meshtastic_MeshPacket *p = allocPacket(0, NODENUM_BROADCAST); + testIface->setActivePacket(p); + testIface->triggerCompleteSending(); + + TEST_ASSERT_EQUAL_UINT32(1, testIface->txGood); + TEST_ASSERT_EQUAL_UINT32(0, testIface->txRelay); +} + +/** + * DM being relayed on behalf of a remote node (from == kRemoteNodeNum ≠ + * kLocalNodeNum). Both txGood and txRelay must be incremented. + */ +static void test_completeSending_relayedDm_countsTxGoodAndTxRelay() +{ + const uint8_t baseCr = 5; + testIface->setBaseCr(baseCr); + testIface->lastAppliedCr = baseCr; + + meshtastic_MeshPacket *p = allocPacket(kRemoteNodeNum, kLocalNodeNum); + testIface->setActivePacket(p); + testIface->triggerCompleteSending(); + + TEST_ASSERT_EQUAL_UINT32(1, testIface->txGood); + TEST_ASSERT_EQUAL_UINT32(1, testIface->txRelay); +} + +/** + * Broadcast being relayed on behalf of a remote node. Both txGood and + * txRelay must be incremented. + */ +static void test_completeSending_relayedBroadcast_countsTxGoodAndTxRelay() +{ + const uint8_t baseCr = 5; + testIface->setBaseCr(baseCr); + testIface->lastAppliedCr = baseCr; + + meshtastic_MeshPacket *p = allocPacket(kRemoteNodeNum, NODENUM_BROADCAST); + testIface->setActivePacket(p); + testIface->triggerCompleteSending(); + + TEST_ASSERT_EQUAL_UINT32(1, testIface->txGood); + TEST_ASSERT_EQUAL_UINT32(1, testIface->txRelay); +} + +// --------------------------------------------------------------------------- +// Section 4 — null-packet guard +// --------------------------------------------------------------------------- + +/** + * When completeSending() fires with sendingPacket == nullptr (e.g. a spurious + * transmit interrupt), it must still drain any active CR override so the radio + * is not left stuck at the override CR. No counters should be touched. + */ +static void test_completeSending_nullPacket_withOverride_restoresBaseCr() +{ + const uint8_t baseCr = 6; + const uint8_t overrideCr = 8; + + testIface->setBaseCr(baseCr); + testIface->lastAppliedCr = overrideCr; + testIface->setActiveTxCodingRateOverrideForTest(overrideCr); + + // Do NOT call setActivePacket — sendingPacket stays nullptr. + testIface->triggerCompleteSending(); + + TEST_ASSERT_EQUAL_UINT8(baseCr, testIface->lastAppliedCr); + TEST_ASSERT_EQUAL_UINT32(0, testIface->txGood); + TEST_ASSERT_EQUAL_UINT32(0, testIface->txRelay); +} + +// --------------------------------------------------------------------------- +// Section 5 — assumption check +// --------------------------------------------------------------------------- + +/** + * Verify the fundamental assumption the fix rests on: a higher override CR + * must produce a longer getPacketTime() result than the base CR. If this + * ever changes (e.g. the encoding is inverted), the ordering fix itself + * becomes irrelevant and someone should revisit the design. + */ +static void test_overrideCr_producesLongerAirtime_thanBaseCr() +{ + const uint8_t baseCr = 5; + const uint8_t overrideCr = 7; + + testIface->lastAppliedCr = baseCr; + uint32_t baseAirtime = testIface->getPacketTime(100, false); + + testIface->lastAppliedCr = overrideCr; + uint32_t overrideAirtime = testIface->getPacketTime(100, false); + + TEST_ASSERT_GREATER_THAN_UINT32(baseAirtime, overrideAirtime); +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +void setup() +{ + delay(10); + delay(2000); + + initializeTestEnvironment(); + + // Created once; setUp()/tearDown() point nodeDB at it for each test. + mockNodeDB = new MinimalMockNodeDB(); + + UNITY_BEGIN(); + + // Section 1 — CR selection policy + RUN_TEST(test_base5_progression); + RUN_TEST(test_base6_progression); + RUN_TEST(test_base7_or_higher_progression); + RUN_TEST(test_second_or_later_retransmission_forces_eight); + + // Section 2 — CR ordering regression + RUN_TEST(test_completeSending_airtimeUsesOverrideCr); + RUN_TEST(test_completeSending_noOverride_seesBaseCr); + RUN_TEST(test_completeSending_relayedPacket_withOverride_usesOverrideCr); + + // Section 3 — counter semantics per send scenario + RUN_TEST(test_completeSending_ownDm_countsTxGoodOnly); + RUN_TEST(test_completeSending_ownBroadcast_countsTxGoodOnly); + RUN_TEST(test_completeSending_relayedDm_countsTxGoodAndTxRelay); + RUN_TEST(test_completeSending_relayedBroadcast_countsTxGoodAndTxRelay); + + // Section 4 — null-packet guard + RUN_TEST(test_completeSending_nullPacket_withOverride_restoresBaseCr); + + // Section 5 — assumption check + RUN_TEST(test_overrideCr_producesLongerAirtime_thanBaseCr); + + exit(UNITY_END()); +} + +void loop() {}