From 781b1ba69b56aae0ddaf6197ed237f42a4acd37c Mon Sep 17 00:00:00 2001 From: nomdetom Date: Fri, 10 Apr 2026 01:00:09 +0100 Subject: [PATCH 01/19] Enable Lite and Narrow regions and introduce getEffectiveDutyCycle for Lite profiles --- src/airtime.cpp | 7 +-- src/graphics/draw/MenuHandler.cpp | 2 +- .../InkHUD/Applets/System/Menu/MenuApplet.cpp | 2 +- src/mesh/MeshRadio.h | 11 ++++- src/mesh/RadioInterface.cpp | 47 +++++++++++++++++-- src/mesh/Router.cpp | 7 +-- 6 files changed, 62 insertions(+), 14 deletions(-) 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 157d4c31443..c7ee9ba59a5 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -179,7 +179,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 } diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index d489d21ee96..7ee839d1443 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -186,7 +186,7 @@ static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region) initRegion(); - if (myRegion && myRegion->dutyCycle < 100) { + if (myRegion && getEffectiveDutyCycle < 100) { config.lora.ignore_mqtt = true; } diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index 3c3a4cf65a6..c8bbf0c21f1 100644 --- a/src/mesh/MeshRadio.h +++ b/src/mesh/MeshRadio.h @@ -22,11 +22,18 @@ struct RegionProfile { uint8_t overrideSlot; // a per-region override slot for if we need to fix it in place }; +/** + * 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 diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 19757b63cb7..268b17a2e9a 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -49,11 +49,19 @@ static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_EU_868[] = { static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_UNDEF[] = {meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, MODEM_PRESET_END}; +static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_LITE[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW, MODEM_PRESET_END}; + +static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_NARROW[] = { + meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST, 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) \ { \ @@ -228,10 +236,24 @@ const RegionInfo regions[] = { */ RDEF(LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, PROFILE_STD), + /* + 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), + + /* + 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(NARROW_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_NARROW), + /* 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), }; @@ -531,6 +553,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; diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 836cd1a2291..5648f2c78e9 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); From 47558652744c4372697e73e0beccd13aed091f1e Mon Sep 17 00:00:00 2001 From: nomdetom Date: Fri, 10 Apr 2026 01:01:11 +0100 Subject: [PATCH 02/19] Add TrafficType enum and extend getConfiguredOrDefaultMsScaled to manage based on regionProfile settings --- src/mesh/Default.cpp | 18 ++++++++++++++++++ src/mesh/Default.h | 4 ++++ 2 files changed, 22 insertions(+) diff --git a/src/mesh/Default.cpp b/src/mesh/Default.cpp index 3ecd766f109..6c50df8ee34 100644 --- a/src/mesh/Default.cpp +++ b/src/mesh/Default.cpp @@ -50,6 +50,24 @@ uint32_t Default::getConfiguredOrDefaultMsScaled(uint32_t configured, uint32_t d return getConfiguredOrDefaultMs(configured, defaultValue) * congestionScalingCoefficient(numOnlineNodes); } +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 069ffc0ebd5..fd59a2f1095 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 // 1 day 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); From c1273048849dd86336eccb1cf4c0feaafe8eb7ea Mon Sep 17 00:00:00 2001 From: nomdetom Date: Fri, 10 Apr 2026 01:02:06 +0100 Subject: [PATCH 03/19] Refactor telemetry modules to include TrafficType in getConfiguredOrDefaultMsScaled calls --- src/modules/AdminModule.cpp | 2 +- src/modules/PositionModule.cpp | 4 ++-- src/modules/Telemetry/AirQualityTelemetry.cpp | 21 ++++++++++--------- src/modules/Telemetry/DeviceTelemetry.cpp | 2 +- .../Telemetry/EnvironmentTelemetry.cpp | 7 ++++--- src/modules/Telemetry/HealthTelemetry.cpp | 7 ++++--- src/modules/Telemetry/PowerTelemetry.cpp | 5 +++-- 7 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 9a52a9aff96..d0c96f3a097 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -818,7 +818,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/PositionModule.cpp b/src/modules/PositionModule.cpp index 0378d01e74b..78243159f07 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -426,8 +426,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 684d408a1cc..bf42c1304b5 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 From fb169b3d58bcee3a8f8ce76aac5fb9c404b72f8a Mon Sep 17 00:00:00 2001 From: nomdetom Date: Fri, 10 Apr 2026 01:11:39 +0100 Subject: [PATCH 04/19] Update submodule protobufs to latest commit --- protobufs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/protobufs b/protobufs index e30092e6168..716a9389363 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit e30092e6168b13341c2b7ec4be19c789ad5cd77f +Subproject commit 716a9389363c894099bd9770c93a3058b486010e From bc47e416a9a6297ec1e3741eda415cd942cb5e7e Mon Sep 17 00:00:00 2001 From: nomdetom Date: Fri, 10 Apr 2026 02:29:27 +0100 Subject: [PATCH 05/19] Add support for new region presets and modem presets in menu options --- src/DisplayFormatters.cpp | 12 +++ src/graphics/draw/MenuHandler.cpp | 90 ++++++++++++------- .../InkHUD/Applets/System/Menu/MenuAction.h | 7 ++ .../InkHUD/Applets/System/Menu/MenuApplet.cpp | 42 +++++++-- 4 files changed, 112 insertions(+), 39 deletions(-) diff --git a/src/DisplayFormatters.cpp b/src/DisplayFormatters.cpp index fdcf840dc28..af07001f6e7 100644 --- a/src/DisplayFormatters.cpp +++ b/src/DisplayFormatters.cpp @@ -38,6 +38,18 @@ const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaC case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: return useShortName ? "LongM" : "LongMod"; break; + case meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST: + return useShortName ? "LiteF" : "LiteFast"; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW: + return useShortName ? "LiteS" : "LiteSlow"; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST: + return useShortName ? "NarF" : "NarrowFast"; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW: + return useShortName ? "NarS" : "NarrowSlow"; + break; default: return useShortName ? "Custom" : "Invalid"; break; diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 734c26c7c86..84e23fd73fb 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" @@ -117,6 +118,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_NARROW_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}, @@ -140,6 +143,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]); @@ -315,42 +319,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/niche/InkHUD/Applets/System/Menu/MenuAction.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h index 7ec76292bf3..31c5ab7e912 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 7ee839d1443..09928c442e0 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -167,6 +167,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) @@ -659,6 +664,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_NARROW_868); + break; + // Roles case SET_ROLE_CLIENT: applyDeviceRole(meshtastic_Config_DeviceConfig_Role_CLIENT); @@ -709,6 +722,15 @@ void InkHUD::MenuApplet::execute(MenuItem item) applyLoRaPreset(meshtastic_Config_LoRaConfig_ModemPreset_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"); @@ -1295,6 +1317,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)); @@ -1324,13 +1348,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; } From 8f701e7ab44557ab62cac54deb4b6b7ef2c835b1 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Tue, 14 Apr 2026 20:31:09 +0100 Subject: [PATCH 06/19] Add new LoRa region codes and modem presets for EU bands --- src/mesh/generated/meshtastic/config.pb.h | 49 ++++++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/src/mesh/generated/meshtastic/config.pb.h b/src/mesh/generated/meshtastic/config.pb.h index c82dd5ff5f0..f4d817ce49b 100644 --- a/src/mesh/generated/meshtastic/config.pb.h +++ b/src/mesh/generated/meshtastic/config.pb.h @@ -285,7 +285,16 @@ typedef enum _meshtastic_Config_LoRaConfig_RegionCode { /* Nepal 865MHz */ meshtastic_Config_LoRaConfig_RegionCode_NP_865 = 25, /* Brazil 902MHz */ - meshtastic_Config_LoRaConfig_RegionCode_BR_902 = 26 + meshtastic_Config_LoRaConfig_RegionCode_BR_902 = 26, + /* + * EU 866MHz band (Band no. 47b of 2006/771/EC and subsequent amendments) for Non-specific short-range devices (SRD) + */ + meshtastic_Config_LoRaConfig_RegionCode_EU_866 = 27, + + /* + * EU 868MHz band, with narrow presets + */ + meshtastic_Config_LoRaConfig_RegionCode_NARROW_868 = 28 } meshtastic_Config_LoRaConfig_RegionCode; /* Standard predefined channel settings @@ -315,7 +324,35 @@ typedef enum _meshtastic_Config_LoRaConfig_ModemPreset { meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO = 8, /* Long Range - Turbo This preset performs similarly to LongFast, but with 500Khz bandwidth. */ - meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO = 9 + meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO = 9, + /* + * Lite Fast + * Medium range preset optimized for EU 866MHz SRD band with 125kHz bandwidth. + * Comparable link budget to MEDIUM_FAST but compliant with Band no. 47b of 2006/771/EC. + */ + meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST = 10, + + /* + * Lite Slow + * Medium-to-moderate range preset optimized for EU 866MHz SRD band with 125kHz bandwidth. + * Comparable link budget to LONG_FAST but compliant with Band no. 47b of 2006/771/EC. + */ + meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW = 11, + + /* + * Narrow Fast + * Medium-to-moderate range preset optimized for EU 868MHz band with 62.5kHz bandwidth. + * Comparable link budget to SHORT_SLOW, but with half the data rate. + * Intended to avoid interference with other devices. + */ + meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST = 12, + + /* + * Narrow Slow + * Moderate range preset optimized for EU 868MHz band with 62.5kHz bandwidth. + * Comparable link budget and data rate to LONG_FAST. + */ + meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW = 13 } meshtastic_Config_LoRaConfig_ModemPreset; typedef enum _meshtastic_Config_LoRaConfig_FEM_LNA_Mode { @@ -702,12 +739,12 @@ extern "C" { #define _meshtastic_Config_DisplayConfig_CompassOrientation_ARRAYSIZE ((meshtastic_Config_DisplayConfig_CompassOrientation)(meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_270_INVERTED+1)) #define _meshtastic_Config_LoRaConfig_RegionCode_MIN meshtastic_Config_LoRaConfig_RegionCode_UNSET -#define _meshtastic_Config_LoRaConfig_RegionCode_MAX meshtastic_Config_LoRaConfig_RegionCode_BR_902 -#define _meshtastic_Config_LoRaConfig_RegionCode_ARRAYSIZE ((meshtastic_Config_LoRaConfig_RegionCode)(meshtastic_Config_LoRaConfig_RegionCode_BR_902+1)) +#define _meshtastic_Config_LoRaConfig_RegionCode_MAX meshtastic_Config_LoRaConfig_RegionCode_NARROW_868 +#define _meshtastic_Config_LoRaConfig_RegionCode_ARRAYSIZE ((meshtastic_Config_LoRaConfig_RegionCode)(meshtastic_Config_LoRaConfig_RegionCode_NARROW_868+1)) #define _meshtastic_Config_LoRaConfig_ModemPreset_MIN meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST -#define _meshtastic_Config_LoRaConfig_ModemPreset_MAX meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO -#define _meshtastic_Config_LoRaConfig_ModemPreset_ARRAYSIZE ((meshtastic_Config_LoRaConfig_ModemPreset)(meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO+1)) +#define _meshtastic_Config_LoRaConfig_ModemPreset_MAX meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW +#define _meshtastic_Config_LoRaConfig_ModemPreset_ARRAYSIZE ((meshtastic_Config_LoRaConfig_ModemPreset)(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW+1)) #define _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED #define _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MAX meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT From 05b364d5ba636d0765044e265537ad269b3c66aa Mon Sep 17 00:00:00 2001 From: nomdetom Date: Tue, 14 Apr 2026 20:33:18 +0100 Subject: [PATCH 07/19] boof --- protobufs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/protobufs b/protobufs index 716a9389363..940ac382a7d 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 716a9389363c894099bd9770c93a3058b486010e +Subproject commit 940ac382a7d143040da5a880237f84c48ee31f2b From e707860737979d7fde87cc7590b2e1555ed39ff9 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Tue, 14 Apr 2026 20:39:17 +0100 Subject: [PATCH 08/19] Add modem presets for LITE and NARROW configurations --- src/mesh/MeshRadio.h | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index 407c4b6facb..b464f037873 100644 --- a/src/mesh/MeshRadio.h +++ b/src/mesh/MeshRadio.h @@ -187,6 +187,26 @@ static inline void modemPresetToParams(meshtastic_Config_LoRaConfig_ModemPreset cr = 8; sf = 12; break; + case meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST: + bwKHz = 125; + cr = 5; + sf = 9; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW: + bwKHz = 125; + cr = 5; + sf = 10; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST: + bwKHz = 62.5f; + cr = 6; + sf = 7; + break; + case meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW: + bwKHz = 62.5f; + cr = 6; + sf = 8; + break; default: // LONG_FAST (or illegal) bwKHz = wideLora ? 812.5f : 250.0f; cr = 5; From e4f75d33fe76867f2c97aae116d2958259a0cbb7 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Tue, 14 Apr 2026 22:16:23 +0100 Subject: [PATCH 09/19] Update subproject commit reference in protobufs --- protobufs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/protobufs b/protobufs index 940ac382a7d..dd81fb52182 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 940ac382a7d143040da5a880237f84c48ee31f2b +Subproject commit dd81fb52182075123823be5bc0ffb0e99f2ca95b From 2361217a81bed67df7aafd0b0134cf52692a5e4e Mon Sep 17 00:00:00 2001 From: NomDeTom <116762865+NomDeTom@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:19:52 +0000 Subject: [PATCH 10/19] Update protobufs --- src/mesh/generated/meshtastic/config.pb.h | 58 ++++++++--------------- 1 file changed, 21 insertions(+), 37 deletions(-) diff --git a/src/mesh/generated/meshtastic/config.pb.h b/src/mesh/generated/meshtastic/config.pb.h index f4d817ce49b..36b3cf17add 100644 --- a/src/mesh/generated/meshtastic/config.pb.h +++ b/src/mesh/generated/meshtastic/config.pb.h @@ -286,15 +286,10 @@ typedef enum _meshtastic_Config_LoRaConfig_RegionCode { meshtastic_Config_LoRaConfig_RegionCode_NP_865 = 25, /* Brazil 902MHz */ meshtastic_Config_LoRaConfig_RegionCode_BR_902 = 26, - /* - * EU 866MHz band (Band no. 47b of 2006/771/EC and subsequent amendments) for Non-specific short-range devices (SRD) - */ - meshtastic_Config_LoRaConfig_RegionCode_EU_866 = 27, - - /* - * EU 868MHz band, with narrow presets - */ - meshtastic_Config_LoRaConfig_RegionCode_NARROW_868 = 28 + /* EU 866MHz band (Band no. 47b of 2006/771/EC and subsequent amendments) for Non-specific short-range devices (SRD) */ + meshtastic_Config_LoRaConfig_RegionCode_EU_866 = 27, + /* EU 868MHz band, with narrow presets */ + meshtastic_Config_LoRaConfig_RegionCode_NARROW_868 = 28 } meshtastic_Config_LoRaConfig_RegionCode; /* Standard predefined channel settings @@ -325,34 +320,23 @@ typedef enum _meshtastic_Config_LoRaConfig_ModemPreset { /* Long Range - Turbo This preset performs similarly to LongFast, but with 500Khz bandwidth. */ meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO = 9, - /* - * Lite Fast - * Medium range preset optimized for EU 866MHz SRD band with 125kHz bandwidth. - * Comparable link budget to MEDIUM_FAST but compliant with Band no. 47b of 2006/771/EC. - */ - meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST = 10, - - /* - * Lite Slow - * Medium-to-moderate range preset optimized for EU 866MHz SRD band with 125kHz bandwidth. - * Comparable link budget to LONG_FAST but compliant with Band no. 47b of 2006/771/EC. - */ - meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW = 11, - - /* - * Narrow Fast - * Medium-to-moderate range preset optimized for EU 868MHz band with 62.5kHz bandwidth. - * Comparable link budget to SHORT_SLOW, but with half the data rate. - * Intended to avoid interference with other devices. - */ - meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST = 12, - - /* - * Narrow Slow - * Moderate range preset optimized for EU 868MHz band with 62.5kHz bandwidth. - * Comparable link budget and data rate to LONG_FAST. - */ - meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW = 13 + /* Lite Fast + Medium range preset optimized for EU 866MHz SRD band with 125kHz bandwidth. + Comparable link budget to MEDIUM_FAST but compliant with Band no. 47b of 2006/771/EC. */ + meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST = 10, + /* Lite Slow + Medium-to-moderate range preset optimized for EU 866MHz SRD band with 125kHz bandwidth. + Comparable link budget to LONG_FAST but compliant with Band no. 47b of 2006/771/EC. */ + meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW = 11, + /* Narrow Fast + Medium-to-moderate range preset optimized for EU 868MHz band with 62.5kHz bandwidth. + Comparable link budget to SHORT_SLOW, but with half the data rate. + Intended to avoid interference with other devices. */ + meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST = 12, + /* Narrow Slow + Moderate range preset optimized for EU 868MHz band with 62.5kHz bandwidth. + Comparable link budget and data rate to LONG_FAST. */ + meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW = 13 } meshtastic_Config_LoRaConfig_ModemPreset; typedef enum _meshtastic_Config_LoRaConfig_FEM_LNA_Mode { From c589bfdb9b091064312d3d725bea17683863be35 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Tue, 14 Apr 2026 22:57:21 +0100 Subject: [PATCH 11/19] Refactor modem preset definitions to use macro for consistency and clarity --- .../InkHUD/Applets/System/Menu/MenuApplet.cpp | 2 +- src/mesh/MeshRadio.h | 7 +- src/mesh/RadioInterface.cpp | 85 +++++++++---------- 3 files changed, 45 insertions(+), 49 deletions(-) diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index 09928c442e0..3bb95d6834f 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -191,7 +191,7 @@ static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region) initRegion(); - if (myRegion && getEffectiveDutyCycle < 100) { + if (myRegion && getEffectiveDutyCycle() < 100) { config.lora.ignore_mqtt = true; } diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index b464f037873..015516df9cc 100644 --- a/src/mesh/MeshRadio.h +++ b/src/mesh/MeshRadio.h @@ -10,9 +10,11 @@ static constexpr meshtastic_Config_LoRaConfig_ModemPreset MODEM_PRESET_END = static_cast(0xFF); +#define PRESET(name) meshtastic_Config_LoRaConfig_ModemPreset_##name + // 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; @@ -47,10 +49,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 { diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index ebea4d09395..6dea1d0d8be 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -34,26 +34,19 @@ #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[] = { - meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW, 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[] = { - meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST, 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 @@ -63,10 +56,10 @@ const RegionProfile PROFILE_UNDEF = {PRESETS_UNDEF, 0, 0, true, false, 0, 1, 1, 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[] = { @@ -74,7 +67,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] @@ -82,7 +75,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/ @@ -97,33 +90,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 @@ -131,13 +124,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. @@ -145,40 +138,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 @@ -187,14 +180,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 @@ -204,9 +197,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 @@ -214,46 +207,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), + 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(NARROW_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_NARROW), + RDEF(NARROW_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)), }; From c8592dc9b0f0b117f6244f5370570edf3b4be6d8 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Wed, 15 Apr 2026 01:58:56 +0100 Subject: [PATCH 12/19] Refactor modem preset cases to use PRESET macro for consistency --- src/DisplayFormatters.cpp | 27 ++++++++++--------- src/graphics/draw/UIRenderer.cpp | 17 ++++++------ .../InkHUD/Applets/System/Menu/MenuApplet.cpp | 17 ++++++------ src/mesh/MeshRadio.h | 24 ++++++++--------- src/modules/CannedMessageModule.cpp | 17 ++++++------ 5 files changed, 53 insertions(+), 49 deletions(-) diff --git a/src/DisplayFormatters.cpp b/src/DisplayFormatters.cpp index af07001f6e7..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,43 +12,43 @@ 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 meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST: + case PRESET(LITE_FAST): return useShortName ? "LiteF" : "LiteFast"; break; - case meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW: + case PRESET(LITE_SLOW): return useShortName ? "LiteS" : "LiteSlow"; break; - case meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST: + case PRESET(NARROW_FAST): return useShortName ? "NarF" : "NarrowFast"; break; - case meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW: + case PRESET(NARROW_SLOW): return useShortName ? "NarS" : "NarrowSlow"; break; default: diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index e3a4d13a258..5d80736d22d 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" @@ -350,16 +351,16 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, i // 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/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index 3bb95d6834f..4fd617caaf8 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" @@ -691,35 +692,35 @@ 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: { diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index 015516df9cc..80e8f2bde97 100644 --- a/src/mesh/MeshRadio.h +++ b/src/mesh/MeshRadio.h @@ -150,62 +150,62 @@ 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 meshtastic_Config_LoRaConfig_ModemPreset_LITE_FAST: + case PRESET(LITE_FAST): bwKHz = 125; cr = 5; sf = 9; break; - case meshtastic_Config_LoRaConfig_ModemPreset_LITE_SLOW: + case PRESET(LITE_SLOW): bwKHz = 125; cr = 5; sf = 10; break; - case meshtastic_Config_LoRaConfig_ModemPreset_NARROW_FAST: + case PRESET(NARROW_FAST): bwKHz = 62.5f; cr = 6; sf = 7; break; - case meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW: + case PRESET(NARROW_SLOW): bwKHz = 62.5f; cr = 6; sf = 8; diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index 65e90313444..760e547e9aa 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; From 5d84c726ec45d039bc47c19fc1fbad61232e6ada Mon Sep 17 00:00:00 2001 From: nomdetom Date: Thu, 30 Apr 2026 22:16:18 +0100 Subject: [PATCH 13/19] fix: update LoRa region code for EU 868 narrowband configuration Co-authored-by: Copilot --- src/graphics/draw/MenuHandler.cpp | 2 +- src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp | 2 +- src/mesh/RadioInterface.cpp | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index c9436825d91..6fc8d2303a1 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -118,7 +118,7 @@ void menuHandler::LoraRegionPicker(uint32_t duration) {"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_NARROW_868}, + {"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}, diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index c1a9a896f3d..8d0cfead0e7 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -781,7 +781,7 @@ void InkHUD::MenuApplet::execute(MenuItem item) break; case SET_REGION_NARROW_868: - applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_NARROW_868); + applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_N_868); break; // Roles diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 6dea1d0d8be..7567902d97b 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -241,7 +241,7 @@ const RegionInfo regions[] = { Channel centres at 869.442/869.525/869.608 MHz, 10.4 kHz padding on channels, 27 dBm, duty cycle 10% */ - RDEF(NARROW_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_NARROW, PRESET(NARROW_SLOW)), + 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. From 8f2010e6dc82aa87901202a025afe5623535a65a Mon Sep 17 00:00:00 2001 From: nomdetom Date: Fri, 1 May 2026 01:36:42 +0100 Subject: [PATCH 14/19] feat: implement coding rate adjustments and add unit tests for retransmission logic Co-authored-by: Copilot --- src/mesh/LR11x0Interface.cpp | 5 ++ src/mesh/LR11x0Interface.h | 2 + src/mesh/NextHopRouter.cpp | 41 +++++++++-- src/mesh/NextHopRouter.h | 16 +++++ src/mesh/RF95Interface.cpp | 5 ++ src/mesh/RF95Interface.h | 2 + src/mesh/RadioInterface.h | 9 +++ src/mesh/RadioLibInterface.cpp | 72 ++++++++++++++++++- src/mesh/RadioLibInterface.h | 29 ++++++++ src/mesh/SX126xInterface.cpp | 5 ++ src/mesh/SX126xInterface.h | 2 + src/mesh/SX128xInterface.cpp | 5 ++ src/mesh/SX128xInterface.h | 2 + .../test_main.cpp | 47 ++++++++++++ 14 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 test/test_retransmission_coding_rate/test_main.cpp diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index 7a193f7f395..86dd64299db 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -152,6 +152,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/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index e8613d45729..3a2b5579444 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -9,12 +9,34 @@ #endif #include "NodeDB.h" +namespace +{ + +uint8_t computeInitialRetransmissionBudget(uint8_t totalRetransmissions) +{ + return totalRetransmissions > 0 ? totalRetransmissions - 1 : 0; +} + +uint8_t getRetransmissionAttempt(const PendingPacket &pending) +{ + return (pending.initialNumRetransmissions - pending.numRetransmissions) + 1; +} + +uint8_t computeRetransmissionCodingRate(const PendingPacket &pending) +{ + return computeDesiredRetransmissionCodingRate(pending.baseCodingRate, getRetransmissionAttempt(pending)); +} + +} // 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 + initialNumRetransmissions = computeInitialRetransmissionBudget(numRetransmissions); + this->numRetransmissions = + initialNumRetransmissions; // The first send already happened before a retransmission record exists. } /** @@ -269,6 +291,7 @@ PendingPacket *NextHopRouter::startRetransmission(meshtastic_MeshPacket *p, uint { auto id = GlobalPacketId(p); auto rec = PendingPacket(p, numReTx); + rec.baseCodingRate = iface ? iface->getCodingRate() : rec.baseCodingRate; stopRetransmission(getFrom(p), p->id); @@ -309,6 +332,12 @@ int32_t NextHopRouter::doRetransmissions() 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); + p.baseCodingRate = iface ? iface->getCodingRate() : p.baseCodingRate; + uint8_t desiredCodingRate = computeRetransmissionCodingRate(p); + iface->setTransmitCodingRateOverride(getFrom(retransmission), retransmission->id, desiredCodingRate); + ErrorCode sendResult = ERRNO_UNKNOWN; + if (!isBroadcast(p.packet->to)) { if (p.numRetransmissions == 1) { // Last retransmission, reset next_hop (fallback to FloodingRouter) @@ -319,14 +348,18 @@ 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) { + iface->clearTransmitCodingRateOverride(getFrom(p.packet), p.packet->id); } // Queue again diff --git a/src/mesh/NextHopRouter.h b/src/mesh/NextHopRouter.h index 42ef13cd9a7..ab6e5cc47bc 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,12 @@ 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; + + /** Base coding rate used to compute retry-specific increments. */ + uint8_t baseCodingRate = 5; + PendingPacket() {} explicit PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions); }; diff --git a/src/mesh/RF95Interface.cpp b/src/mesh/RF95Interface.cpp index 32c92de93ab..d624d1b50bb 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.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..62e40ce7478 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,72 @@ 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); + } + + activeTxCodingRateOverride.reset(); +} + /** * Remove a packet that is eligible for replacement from the TX queue */ @@ -434,6 +501,7 @@ void RadioLibInterface::completeSending() // that can take a long time auto p = sendingPacket; sendingPacket = NULL; + restoreTemporaryCodingRateOverride(); if (p) { // Packet has been sent, count it toward our TX airtime utilization. @@ -589,9 +657,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..443fa039c0d 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,9 @@ 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; + /** * 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 +232,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 +247,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/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 44c4a805ac3..1dd38c91d08 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 cb21c0770d6..2a586096274 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/test/test_retransmission_coding_rate/test_main.cpp b/test/test_retransmission_coding_rate/test_main.cpp new file mode 100644 index 00000000000..7a1511cadb0 --- /dev/null +++ b/test/test_retransmission_coding_rate/test_main.cpp @@ -0,0 +1,47 @@ +#include "MeshTypes.h" +#include "NextHopRouter.h" +#include "TestUtil.h" +#include + +void setUp(void) {} +void tearDown(void) {} + +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)); +} + +void setup() +{ + initializeTestEnvironment(); + + UNITY_BEGIN(); + 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); + exit(UNITY_END()); +} + +void loop() {} From cd2872ea3bbeae23b60937d134f69ea65cf9e521 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Fri, 1 May 2026 01:53:00 +0100 Subject: [PATCH 15/19] refactor: simplify retransmission logic and improve logging for coding rate restoration Co-authored-by: Copilot --- src/mesh/NextHopRouter.cpp | 30 +++++++++++------------------- src/mesh/NextHopRouter.h | 3 --- src/mesh/RadioLibInterface.cpp | 2 ++ 3 files changed, 13 insertions(+), 22 deletions(-) diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index 3a2b5579444..74b8b4dbdd4 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -12,21 +12,14 @@ namespace { -uint8_t computeInitialRetransmissionBudget(uint8_t totalRetransmissions) -{ - return totalRetransmissions > 0 ? totalRetransmissions - 1 : 0; -} - +// 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; } -uint8_t computeRetransmissionCodingRate(const PendingPacket &pending) -{ - return computeDesiredRetransmissionCodingRate(pending.baseCodingRate, getRetransmissionAttempt(pending)); -} - } // namespace NextHopRouter::NextHopRouter() {} @@ -34,9 +27,9 @@ NextHopRouter::NextHopRouter() {} PendingPacket::PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions) { packet = p; - initialNumRetransmissions = computeInitialRetransmissionBudget(numRetransmissions); - this->numRetransmissions = - initialNumRetransmissions; // The first send already happened before a retransmission record exists. + // Subtract one because the first send has already happened before this record is created. + initialNumRetransmissions = numRetransmissions > 0 ? numRetransmissions - 1 : 0; + this->numRetransmissions = initialNumRetransmissions; } /** @@ -291,7 +284,6 @@ PendingPacket *NextHopRouter::startRetransmission(meshtastic_MeshPacket *p, uint { auto id = GlobalPacketId(p); auto rec = PendingPacket(p, numReTx); - rec.baseCodingRate = iface ? iface->getCodingRate() : rec.baseCodingRate; stopRetransmission(getFrom(p), p->id); @@ -329,12 +321,11 @@ int32_t NextHopRouter::doRetransmissions() 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); - p.baseCodingRate = iface ? iface->getCodingRate() : p.baseCodingRate; - uint8_t desiredCodingRate = computeRetransmissionCodingRate(p); + 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; @@ -359,6 +350,7 @@ int32_t NextHopRouter::doRetransmissions() } 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); } diff --git a/src/mesh/NextHopRouter.h b/src/mesh/NextHopRouter.h index ab6e5cc47bc..9d14225c0c9 100644 --- a/src/mesh/NextHopRouter.h +++ b/src/mesh/NextHopRouter.h @@ -52,9 +52,6 @@ struct PendingPacket { /** The initial retransmission budget after the first send has already happened. */ uint8_t initialNumRetransmissions = 0; - /** Base coding rate used to compute retry-specific increments. */ - uint8_t baseCodingRate = 5; - PendingPacket() {} explicit PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions); }; diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 62e40ce7478..487f9f06d53 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -476,6 +476,8 @@ void RadioLibInterface::restoreTemporaryCodingRateOverride() 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(); From 7191ed1e5a34ef0d7bdfaa490abb801f575757b0 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Fri, 1 May 2026 02:02:14 +0100 Subject: [PATCH 16/19] feat: add retransmission failure count to Router class Co-authored-by: Copilot --- src/mesh/NextHopRouter.cpp | 1 + src/mesh/Router.h | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index 74b8b4dbdd4..87c084df54a 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -317,6 +317,7 @@ 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 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; From 147e8a0f7744959b4ae30b22f9bb7facdef329cb Mon Sep 17 00:00:00 2001 From: nomdetom Date: Sun, 3 May 2026 20:32:56 +0100 Subject: [PATCH 17/19] fix: correct airtime calculation Co-authored-by: Copilot --- src/mesh/RadioLibInterface.cpp | 10 +- src/mesh/RadioLibInterface.h | 4 + .../TestRadioLibInterface.h | 82 ++++++++ test/test_coding_rate_airtime/test_main.cpp | 183 ++++++++++++++++++ 4 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 test/test_coding_rate_airtime/TestRadioLibInterface.h create mode 100644 test/test_coding_rate_airtime/test_main.cpp diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 487f9f06d53..71d1bffb023 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -503,11 +503,15 @@ void RadioLibInterface::completeSending() // that can take a long time auto p = sendingPacket; sendingPacket = NULL; - restoreTemporaryCodingRateOverride(); 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++; @@ -517,6 +521,8 @@ void RadioLibInterface::completeSending() // We are done sending that packet, release it packetPool.release(p); + } else { + restoreTemporaryCodingRateOverride(); } } diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h index 443fa039c0d..3a9338d8e32 100644 --- a/src/mesh/RadioLibInterface.h +++ b/src/mesh/RadioLibInterface.h @@ -202,6 +202,10 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified 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. diff --git a/test/test_coding_rate_airtime/TestRadioLibInterface.h b/test/test_coding_rate_airtime/TestRadioLibInterface.h new file mode 100644 index 00000000000..dbcc94dc203 --- /dev/null +++ b/test/test_coding_rate_airtime/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_coding_rate_airtime/test_main.cpp b/test/test_coding_rate_airtime/test_main.cpp new file mode 100644 index 00000000000..dbe47d73880 --- /dev/null +++ b/test/test_coding_rate_airtime/test_main.cpp @@ -0,0 +1,183 @@ +/** + * Regression tests for the airtime calculation ordering in + * RadioLibInterface::completeSending(). + * + * The bug: completeSending() used to call restoreTemporaryCodingRateOverride() + * BEFORE getPacketTime(). Because the TX path for retransmissions with an + * escalated CR calls applyCodingRate(overrideCr) on the hardware, and + * getPacketTime() reads that hardware state, the early restore left the radio + * at the base CR when getPacketTime() ran — producing an airtime that was too + * short. + * + * The fix: getPacketTime(p) is now called first, while the hardware is still + * at the override CR, and restoreTemporaryCodingRateOverride() runs afterwards. + * + * This suite exercises completeSending() through a minimal test double and + * verifies the CR seen by getPacketTime() equals the override CR, not the + * base CR. + */ + +#include "MeshTypes.h" +#include "NodeDB.h" +#include "TestRadioLibInterface.h" // test double + wrappers +#include "TestUtil.h" +#include "airtime.h" +#include + +// --------------------------------------------------------------------------- +// Test globals +// --------------------------------------------------------------------------- + +static TestRadioLibInterface *testIface = nullptr; +static AirTime *savedAirTime = nullptr; +static AirTime *testAirTime = nullptr; + +// --------------------------------------------------------------------------- +// Unity lifecycle +// --------------------------------------------------------------------------- + +void setUp(void) +{ + // AirTime::logAirtime() must not dereference a null pointer. + // Construct a real AirTime; its logAirtime() simply adds to internal + // counters which is harmless for our test. + savedAirTime = airTime; + testAirTime = new AirTime(); + airTime = testAirTime; + + // isFromUs(p) with p->from == 0 short-circuits before touching nodeDB + // (the LHS of the || is true), so no nodeDB setup is required. + + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + + testIface = new TestRadioLibInterface(); +} + +void tearDown(void) +{ + delete testIface; + testIface = nullptr; + + airTime = savedAirTime; + delete testAirTime; + testAirTime = nullptr; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +/** + * When completeSending() fires 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; + + // Set the base coding rate on the RadioInterface (as applyModemConfig would). + testIface->setBaseCr(baseCr); + + // Simulate the hardware state AFTER applyTemporaryCodingRateOverride() ran: + // the radio was put into the override CR. + testIface->lastAppliedCr = overrideCr; + + // Simulate an active override (set by applyTemporaryCodingRateOverride). + testIface->setActiveTxCodingRateOverrideForTest(overrideCr); + + // Allocate a packet from the real pool so packetPool.release() inside + // completeSending() doesn't corrupt memory. Use p->from == 0 so that + // isFromUs() short-circuits to true without touching nodeDB. + meshtastic_MeshPacket *p = packetPool.allocZeroed(); + p->from = 0; + p->which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p->encrypted.size = 10; + + testIface->setActivePacket(p); + + testIface->triggerCompleteSending(); + + // The CR recorded by getPacketTime() must be the override CR, proving that + // getPacketTime() ran before restoreTemporaryCodingRateOverride() restored + // the hardware to baseCr. + TEST_ASSERT_EQUAL_UINT8(overrideCr, testIface->crSeenByGetPacketTime); + + // After completeSending(), the override must be cleared and the radio + // restored to the base CR. + TEST_ASSERT_EQUAL_UINT8(baseCr, testIface->lastAppliedCr); +} + +/** + * When there is NO active override (normal, non-retransmission send), + * completeSending() must still work correctly: getPacketTime() sees 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; + // Leave activeTxCodingRateOverride unset (nullopt). + + meshtastic_MeshPacket *p = packetPool.allocZeroed(); + p->from = 0; + p->which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p->encrypted.size = 10; + + testIface->setActivePacket(p); + + testIface->triggerCompleteSending(); + + TEST_ASSERT_EQUAL_UINT8(baseCr, testIface->crSeenByGetPacketTime); + // No override was active, so lastAppliedCr stays at baseCr (restore + // is a no-op and no applyCodingRate call is made). + TEST_ASSERT_EQUAL_UINT8(baseCr, testIface->lastAppliedCr); +} + +/** + * 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(); + + UNITY_BEGIN(); + RUN_TEST(test_completeSending_airtimeUsesOverrideCr); + RUN_TEST(test_completeSending_noOverride_seesBaseCr); + RUN_TEST(test_overrideCr_producesLongerAirtime_thanBaseCr); + exit(UNITY_END()); +} + +void loop() {} From 30fa5a706dbc1cd78e78d89e9f2293dcf14f0377 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Sun, 3 May 2026 21:35:36 +0100 Subject: [PATCH 18/19] improve retransmission coding-rate selection and airtime calculation tests Co-authored-by: Copilot --- test/test_coding_rate_airtime/test_main.cpp | 183 ---------- .../TestRadioLibInterface.h | 0 .../test_main.cpp | 341 +++++++++++++++++- 3 files changed, 339 insertions(+), 185 deletions(-) delete mode 100644 test/test_coding_rate_airtime/test_main.cpp rename test/{test_coding_rate_airtime => test_retransmission_coding_rate}/TestRadioLibInterface.h (100%) diff --git a/test/test_coding_rate_airtime/test_main.cpp b/test/test_coding_rate_airtime/test_main.cpp deleted file mode 100644 index dbe47d73880..00000000000 --- a/test/test_coding_rate_airtime/test_main.cpp +++ /dev/null @@ -1,183 +0,0 @@ -/** - * Regression tests for the airtime calculation ordering in - * RadioLibInterface::completeSending(). - * - * The bug: completeSending() used to call restoreTemporaryCodingRateOverride() - * BEFORE getPacketTime(). Because the TX path for retransmissions with an - * escalated CR calls applyCodingRate(overrideCr) on the hardware, and - * getPacketTime() reads that hardware state, the early restore left the radio - * at the base CR when getPacketTime() ran — producing an airtime that was too - * short. - * - * The fix: getPacketTime(p) is now called first, while the hardware is still - * at the override CR, and restoreTemporaryCodingRateOverride() runs afterwards. - * - * This suite exercises completeSending() through a minimal test double and - * verifies the CR seen by getPacketTime() equals the override CR, not the - * base CR. - */ - -#include "MeshTypes.h" -#include "NodeDB.h" -#include "TestRadioLibInterface.h" // test double + wrappers -#include "TestUtil.h" -#include "airtime.h" -#include - -// --------------------------------------------------------------------------- -// Test globals -// --------------------------------------------------------------------------- - -static TestRadioLibInterface *testIface = nullptr; -static AirTime *savedAirTime = nullptr; -static AirTime *testAirTime = nullptr; - -// --------------------------------------------------------------------------- -// Unity lifecycle -// --------------------------------------------------------------------------- - -void setUp(void) -{ - // AirTime::logAirtime() must not dereference a null pointer. - // Construct a real AirTime; its logAirtime() simply adds to internal - // counters which is harmless for our test. - savedAirTime = airTime; - testAirTime = new AirTime(); - airTime = testAirTime; - - // isFromUs(p) with p->from == 0 short-circuits before touching nodeDB - // (the LHS of the || is true), so no nodeDB setup is required. - - config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; - initRegion(); - - testIface = new TestRadioLibInterface(); -} - -void tearDown(void) -{ - delete testIface; - testIface = nullptr; - - airTime = savedAirTime; - delete testAirTime; - testAirTime = nullptr; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -/** - * When completeSending() fires 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; - - // Set the base coding rate on the RadioInterface (as applyModemConfig would). - testIface->setBaseCr(baseCr); - - // Simulate the hardware state AFTER applyTemporaryCodingRateOverride() ran: - // the radio was put into the override CR. - testIface->lastAppliedCr = overrideCr; - - // Simulate an active override (set by applyTemporaryCodingRateOverride). - testIface->setActiveTxCodingRateOverrideForTest(overrideCr); - - // Allocate a packet from the real pool so packetPool.release() inside - // completeSending() doesn't corrupt memory. Use p->from == 0 so that - // isFromUs() short-circuits to true without touching nodeDB. - meshtastic_MeshPacket *p = packetPool.allocZeroed(); - p->from = 0; - p->which_payload_variant = meshtastic_MeshPacket_encrypted_tag; - p->encrypted.size = 10; - - testIface->setActivePacket(p); - - testIface->triggerCompleteSending(); - - // The CR recorded by getPacketTime() must be the override CR, proving that - // getPacketTime() ran before restoreTemporaryCodingRateOverride() restored - // the hardware to baseCr. - TEST_ASSERT_EQUAL_UINT8(overrideCr, testIface->crSeenByGetPacketTime); - - // After completeSending(), the override must be cleared and the radio - // restored to the base CR. - TEST_ASSERT_EQUAL_UINT8(baseCr, testIface->lastAppliedCr); -} - -/** - * When there is NO active override (normal, non-retransmission send), - * completeSending() must still work correctly: getPacketTime() sees 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; - // Leave activeTxCodingRateOverride unset (nullopt). - - meshtastic_MeshPacket *p = packetPool.allocZeroed(); - p->from = 0; - p->which_payload_variant = meshtastic_MeshPacket_encrypted_tag; - p->encrypted.size = 10; - - testIface->setActivePacket(p); - - testIface->triggerCompleteSending(); - - TEST_ASSERT_EQUAL_UINT8(baseCr, testIface->crSeenByGetPacketTime); - // No override was active, so lastAppliedCr stays at baseCr (restore - // is a no-op and no applyCodingRate call is made). - TEST_ASSERT_EQUAL_UINT8(baseCr, testIface->lastAppliedCr); -} - -/** - * 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(); - - UNITY_BEGIN(); - RUN_TEST(test_completeSending_airtimeUsesOverrideCr); - RUN_TEST(test_completeSending_noOverride_seesBaseCr); - RUN_TEST(test_overrideCr_producesLongerAirtime_thanBaseCr); - exit(UNITY_END()); -} - -void loop() {} diff --git a/test/test_coding_rate_airtime/TestRadioLibInterface.h b/test/test_retransmission_coding_rate/TestRadioLibInterface.h similarity index 100% rename from test/test_coding_rate_airtime/TestRadioLibInterface.h rename to test/test_retransmission_coding_rate/TestRadioLibInterface.h diff --git a/test/test_retransmission_coding_rate/test_main.cpp b/test/test_retransmission_coding_rate/test_main.cpp index 7a1511cadb0..e3b594e8d5d 100644 --- a/test/test_retransmission_coding_rate/test_main.cpp +++ b/test/test_retransmission_coding_rate/test_main.cpp @@ -1,10 +1,112 @@ +/** + * 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 -void setUp(void) {} -void tearDown(void) {} +// --------------------------------------------------------------------------- +// 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() { @@ -32,15 +134,250 @@ static void test_second_or_later_retransmission_forces_eight() 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()); } From f4ce64cd8b5a8267a6befb7a2012981601c630bd Mon Sep 17 00:00:00 2001 From: nomdetom Date: Wed, 6 May 2026 16:39:07 +0100 Subject: [PATCH 19/19] OVERRIDE_SLOT override --- src/mesh/MeshRadio.h | 8 +- src/mesh/RadioInterface.cpp | 42 +++--- test/test_admin_radio/test_main.cpp | 196 +++++++++++++++++++++++++++- 3 files changed, 226 insertions(+), 20 deletions(-) diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index 9abab54146d..e2c053a8bed 100644 --- a/src/mesh/MeshRadio.h +++ b/src/mesh/MeshRadio.h @@ -15,6 +15,11 @@ static const meshtastic_Config_LoRaConfig_ModemPreset MODEM_PRESET_END = #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 @@ -25,7 +30,8 @@ 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 }; /** diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 7567902d97b..c828a7a2330 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -914,12 +914,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); @@ -934,10 +937,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 @@ -1035,6 +1042,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; @@ -1051,11 +1060,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; @@ -1068,7 +1079,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 @@ -1085,9 +1095,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/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);