From f75edf2a21fb119eac789779bb2d9e2f0f5aaeaf Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Sun, 4 Jan 2026 18:21:38 -0600
Subject: [PATCH 1/5] feat: Add new regions and modem presets
This commit introduces several new regions and corresponding modem presets to align with recent firmware updates.
- Adds `EU_866`, `NARROW_868`, and `HAM_US433` regions.
- Adds new modem presets: `LITE_FAST`, `LITE_SLOW`, `NARROW_FAST`, and `NARROW_SLOW`.
- Updates the channel calculation logic to account for channel spacing, matching the firmware's behavior.
- Sets the default modem preset automatically when a new region is selected.
- Updates the protobuf submodule to the latest version.
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
.../org/meshtastic/core/model/Channel.kt | 4 ++
.../meshtastic/core/model/ChannelOption.kt | 67 +++++++++++++++++--
.../composeResources/values/strings.xml | 4 ++
.../radio/component/LoRaConfigItemList.kt | 14 +++-
4 files changed, 80 insertions(+), 9 deletions(-)
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/Channel.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/Channel.kt
index b333681722..239e750586 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/Channel.kt
+++ b/core/model/src/main/kotlin/org/meshtastic/core/model/Channel.kt
@@ -94,6 +94,10 @@ data class Channel(
ModemPreset.LONG_MODERATE -> "LongMod"
ModemPreset.VERY_LONG_SLOW -> "VLongSlow"
ModemPreset.LONG_TURBO -> "LongTurbo"
+ ModemPreset.LITE_FAST -> "LiteFast"
+ ModemPreset.LITE_SLOW -> "LiteSlow"
+ ModemPreset.NARROW_FAST -> "NarrowFast"
+ ModemPreset.NARROW_SLOW -> "NarrowSlow"
else -> "Invalid"
}
} else {
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/ChannelOption.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/ChannelOption.kt
index 9589770531..d7b9d3c0b1 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/ChannelOption.kt
+++ b/core/model/src/main/kotlin/org/meshtastic/core/model/ChannelOption.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,19 +14,22 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
@file:Suppress("MagicNumber")
package org.meshtastic.core.model
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.label_lite_fast
+import org.meshtastic.core.strings.label_lite_slow
import org.meshtastic.core.strings.label_long_fast
import org.meshtastic.core.strings.label_long_moderate
import org.meshtastic.core.strings.label_long_slow
import org.meshtastic.core.strings.label_long_turbo
import org.meshtastic.core.strings.label_medium_fast
import org.meshtastic.core.strings.label_medium_slow
+import org.meshtastic.core.strings.label_narrow_fast
+import org.meshtastic.core.strings.label_narrow_slow
import org.meshtastic.core.strings.label_short_fast
import org.meshtastic.core.strings.label_short_slow
import org.meshtastic.core.strings.label_short_turbo
@@ -34,7 +37,6 @@ import org.meshtastic.core.strings.label_very_long_slow
import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig
import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig.ModemPreset
import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig.RegionCode
-import kotlin.math.floor
/** hash a string into an integer using the djb2 algorithm by Dan Bernstein http://www.cse.yorku.ca/~oz/hash.html */
private fun hash(name: String): UInt { // using UInt instead of Long to match RadioInterface.cpp results
@@ -75,10 +77,15 @@ val LoRaConfig.numChannels: Int
val bw = bandwidth(regionInfo)
if (bw <= 0f) return 1 // Return 1 if bandwidth is zero or negative
- val num = floor((regionInfo.freqEnd - regionInfo.freqStart) / bw)
+ // Calculate number of channels: spacing = gap between channels (0 for continuous spectrum)
+ // Match firmware: uint32_t numChannels = round((myRegion->freqEnd - myRegion->freqStart + myRegion->spacing) /
+ // channelSpacing);
+ val channelSpacing = regionInfo.spacing + bw
+ val num = Math.round((regionInfo.freqEnd - regionInfo.freqStart + regionInfo.spacing) / channelSpacing).toInt()
+
// If the regional frequency range is smaller than the bandwidth, the firmware would
// fall back to a default preset. In the app, we return 1 to avoid a crash.
- return if (num > 0) num.toInt() else 1
+ return if (num > 0) num else 1
}
internal fun LoRaConfig.channelNum(primaryName: String): Int = when {
@@ -91,7 +98,15 @@ internal fun LoRaConfig.radioFreq(channelNum: Int): Float {
if (overrideFrequency != 0f) return overrideFrequency + frequencyOffset
val regionInfo = RegionInfo.fromRegionCode(region)
return if (regionInfo != null) {
- (regionInfo.freqStart + bandwidth(regionInfo) / 2) + (channelNum - 1) * bandwidth(regionInfo)
+ val bw = bandwidth(regionInfo)
+ val channelSpacing = regionInfo.spacing + bw
+ // Match firmware: float freq = myRegion->freqStart + (bw / 2000) + (channel_num * channelSpacing);
+ // Note: firmware channel_num is 0-indexed in the calculation, but the app uses 1-indexed for some reason?
+ // Let's re-verify the firmware logic.
+ // Firmware: channel_num = hash(channelName) % numChannels;
+ // freq = myRegion->freqStart + (bw / 2000) + (channel_num * channelSpacing);
+ // The app's channelNum function returns 1..numChannels if channelNum is 0.
+ (regionInfo.freqStart + bw / 2) + (channelNum - 1) * channelSpacing
} else {
0f
}
@@ -105,6 +120,8 @@ internal fun LoRaConfig.radioFreq(channelNum: Int): Float {
* @property freqStart The starting frequency in MHz
* @property freqEnd The ending frequency in MHz
* @property wideLora Whether the region uses wide Lora
+ * @property spacing The gap between channels in MHz
+ * @property defaultPreset The default modem preset for this region
* @see
* [LoRaWAN Regional Parameters](https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf)
*/
@@ -115,6 +132,8 @@ enum class RegionInfo(
val freqStart: Float,
val freqEnd: Float,
val wideLora: Boolean = false,
+ val spacing: Float = 0f,
+ val defaultPreset: ModemPreset = ModemPreset.LONG_FAST,
) {
/** This needs to be last. Same as US. */
UNSET(RegionCode.UNSET, "Please set a region", 902.0f, 928.0f),
@@ -301,6 +320,38 @@ enum class RegionInfo(
* @see [Firmware Issue #7399](https://github.com/meshtastic/firmware/pull/7399)
*/
BR_902(RegionCode.BR_902, "Brazil 902MHz", 902.0f, 907.5f, wideLora = false),
+
+ /** EU 866MHz RFID band */
+ EU_866(
+ RegionCode.EU_866,
+ "European Union 866MHz",
+ 865.6375f,
+ 867.5625f,
+ wideLora = false,
+ spacing = 0.475f,
+ defaultPreset = ModemPreset.LITE_FAST,
+ ),
+
+ /** EU 868MHz band, with narrow presets */
+ NARROW_868(
+ RegionCode.NARROW_868,
+ "European Union 868MHz (Narrow)",
+ 869.4f,
+ 869.65f,
+ wideLora = false,
+ spacing = 0.015f,
+ defaultPreset = ModemPreset.NARROW_FAST,
+ ),
+
+ /** US 433MHz Amateur Use band */
+ HAM_US433(
+ RegionCode.HAM_US433,
+ "United States 433MHz (Amateur)",
+ 430.0f,
+ 450.0f,
+ wideLora = false,
+ defaultPreset = ModemPreset.NARROW_SLOW,
+ ),
;
companion object {
@@ -320,6 +371,10 @@ enum class ChannelOption(val modemPreset: ModemPreset, val labelRes: StringResou
SHORT_FAST(ModemPreset.SHORT_FAST, Res.string.label_short_fast, 0.250f),
SHORT_SLOW(ModemPreset.SHORT_SLOW, Res.string.label_short_slow, 0.250f),
SHORT_TURBO(ModemPreset.SHORT_TURBO, Res.string.label_short_turbo, 0.500f),
+ LITE_FAST(ModemPreset.LITE_FAST, Res.string.label_lite_fast, 0.125f),
+ LITE_SLOW(ModemPreset.LITE_SLOW, Res.string.label_lite_slow, 0.125f),
+ NARROW_FAST(ModemPreset.NARROW_FAST, Res.string.label_narrow_fast, 0.0625f),
+ NARROW_SLOW(ModemPreset.NARROW_SLOW, Res.string.label_narrow_slow, 0.0625f),
;
companion object {
diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml
index 8a0a4f4f83..10f1a2fd68 100644
--- a/core/strings/src/commonMain/composeResources/values/strings.xml
+++ b/core/strings/src/commonMain/composeResources/values/strings.xml
@@ -150,6 +150,10 @@
Short Range - Turbo
Short Range - Fast
Short Range - Slow
+ Lite - Fast
+ Lite - Slow
+ Narrow - Fast
+ Narrow - Slow
Enabling WiFi will disable the bluetooth connection to the app.
Enabling Ethernet will disable the bluetooth connection to the app. TCP node connections are not available on Apple devices.
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt
index a83dba3823..3a0e4c7e02 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.feature.settings.radio.component
import androidx.compose.foundation.text.KeyboardActions
@@ -96,7 +95,16 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
enabled = state.connected,
items = RegionInfo.entries.map { it.regionCode to it.description },
selectedItem = formState.value.region,
- onItemSelected = { formState.value = formState.value.copy { region = it } },
+ onItemSelected = {
+ val regionInfo = RegionInfo.fromRegionCode(it)
+ formState.value =
+ formState.value.copy {
+ region = it
+ if (regionInfo != null) {
+ modemPreset = regionInfo.defaultPreset
+ }
+ }
+ },
)
HorizontalDivider()
SwitchPreference(
From 0724924f8c3d5777dd791c566349d4fa0a16a556 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Fri, 23 Jan 2026 08:42:14 -0600
Subject: [PATCH 2/5] Fix channel calculation for NARROW_868 region
Correct the `numChannels` and `radioFreq` calculations to align with the firmware logic. This primarily affects the NARROW_868 region by properly accounting for channel spacing.
- Remove `regionInfo.spacing` from the `numChannels` numerator.
- Add `regionInfo.spacing` to the `radioFreq` calculation.
- Add tests to verify `numChannels` and `radioFreq` for the `NARROW_868` region.
---
.../meshtastic/core/model/ChannelOption.kt | 12 ++++-----
.../core/model/ChannelOptionTest.kt | 25 +++++++++++++++++--
2 files changed, 29 insertions(+), 8 deletions(-)
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/ChannelOption.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/ChannelOption.kt
index d7b9d3c0b1..38bc6f5f02 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/ChannelOption.kt
+++ b/core/model/src/main/kotlin/org/meshtastic/core/model/ChannelOption.kt
@@ -78,10 +78,9 @@ val LoRaConfig.numChannels: Int
if (bw <= 0f) return 1 // Return 1 if bandwidth is zero or negative
// Calculate number of channels: spacing = gap between channels (0 for continuous spectrum)
- // Match firmware: uint32_t numChannels = round((myRegion->freqEnd - myRegion->freqStart + myRegion->spacing) /
- // channelSpacing);
+ // Match firmware: uint32_t numChannels = round((myRegion->freqEnd - myRegion->freqStart) / channelSpacing);
val channelSpacing = regionInfo.spacing + bw
- val num = Math.round((regionInfo.freqEnd - regionInfo.freqStart + regionInfo.spacing) / channelSpacing).toInt()
+ val num = Math.round((regionInfo.freqEnd - regionInfo.freqStart) / channelSpacing).toInt()
// If the regional frequency range is smaller than the bandwidth, the firmware would
// fall back to a default preset. In the app, we return 1 to avoid a crash.
@@ -100,13 +99,14 @@ internal fun LoRaConfig.radioFreq(channelNum: Int): Float {
return if (regionInfo != null) {
val bw = bandwidth(regionInfo)
val channelSpacing = regionInfo.spacing + bw
- // Match firmware: float freq = myRegion->freqStart + (bw / 2000) + (channel_num * channelSpacing);
+ // Match firmware: float freq = myRegion->freqStart + myRegion->spacing + (bw / 2000) + (channel_num *
+ // channelSpacing);
// Note: firmware channel_num is 0-indexed in the calculation, but the app uses 1-indexed for some reason?
// Let's re-verify the firmware logic.
// Firmware: channel_num = hash(channelName) % numChannels;
- // freq = myRegion->freqStart + (bw / 2000) + (channel_num * channelSpacing);
+ // freq = myRegion->freqStart + myRegion->spacing + (bw / 2000) + (channel_num * channelSpacing);
// The app's channelNum function returns 1..numChannels if channelNum is 0.
- (regionInfo.freqStart + bw / 2) + (channelNum - 1) * channelSpacing
+ (regionInfo.freqStart + regionInfo.spacing + bw / 2) + (channelNum - 1) * channelSpacing
} else {
0f
}
diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt b/core/model/src/test/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt
index 70197d8e2a..cdd0b5ce5a 100644
--- a/core/model/src/test/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt
+++ b/core/model/src/test/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,13 +14,14 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.core.model
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
+import org.meshtastic.proto.ConfigKt.loRaConfig
import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig.ModemPreset
+import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig.RegionCode
class ChannelOptionTest {
@@ -78,4 +79,24 @@ class ChannelOptionTest {
ChannelOption.entries.size,
)
}
+
+ @Test
+ fun `test radioFreq and numChannels for NARROW_868`() {
+ val loraConfig = loRaConfig {
+ region = RegionCode.NARROW_868
+ usePreset = true
+ modemPreset = ModemPreset.NARROW_FAST
+ }
+
+ // bw = 0.0625, spacing = 0.015, channelSpacing = 0.0775
+ // Range = 869.65 - 869.4 = 0.25
+ // numChannels = round(0.25 / 0.0775) = 3
+ assertEquals(3, loraConfig.numChannels)
+
+ // Slot 1: freqStart + spacing + bw/2 = 869.4 + 0.015 + 0.03125 = 869.44625
+ assertEquals(869.44625f, loraConfig.radioFreq(1), 0.0001f)
+
+ // Slot 3: 869.44625 + 2 * 0.0775 = 869.44625 + 0.155 = 869.60125
+ assertEquals(869.60125f, loraConfig.radioFreq(3), 0.0001f)
+ }
}
From 0d9c1a6c02e0bc71e33d8d48a66e7c43dce509db Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Fri, 23 Jan 2026 11:06:34 -0600
Subject: [PATCH 3/5] Fix frequency calculation for amateur radio regions
The frequency calculation for amateur radio regions was incorrectly including `regionInfo.spacing`. This change removes the spacing component from the calculation when the region description contains "Amateur", aligning the behavior with the firmware.
---
.../org/meshtastic/core/model/ChannelOption.kt | 16 +++++++++-------
1 file changed, 9 insertions(+), 7 deletions(-)
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/ChannelOption.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/ChannelOption.kt
index 38bc6f5f02..fdd708d8a3 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/ChannelOption.kt
+++ b/core/model/src/main/kotlin/org/meshtastic/core/model/ChannelOption.kt
@@ -99,14 +99,16 @@ internal fun LoRaConfig.radioFreq(channelNum: Int): Float {
return if (regionInfo != null) {
val bw = bandwidth(regionInfo)
val channelSpacing = regionInfo.spacing + bw
- // Match firmware: float freq = myRegion->freqStart + myRegion->spacing + (bw / 2000) + (channel_num *
+ // The frequency calculation attempts to match firmware behavior,
+ // where channel_num is 0-indexed in the calculation, but 1-indexed in the app's channelNum function.
+ // For amateur radio regions, regionInfo.spacing is not included in the frequency calculation.
+ // Firmware example: float freq = myRegion->freqStart + myRegion->spacing + (bw / 2000) + (channel_num *
// channelSpacing);
- // Note: firmware channel_num is 0-indexed in the calculation, but the app uses 1-indexed for some reason?
- // Let's re-verify the firmware logic.
- // Firmware: channel_num = hash(channelName) % numChannels;
- // freq = myRegion->freqStart + myRegion->spacing + (bw / 2000) + (channel_num * channelSpacing);
- // The app's channelNum function returns 1..numChannels if channelNum is 0.
- (regionInfo.freqStart + regionInfo.spacing + bw / 2) + (channelNum - 1) * channelSpacing
+ if (regionInfo.description.contains("Amateur")) {
+ (regionInfo.freqStart + bw / 2) + (channelNum - 1) * channelSpacing
+ } else {
+ (regionInfo.freqStart + regionInfo.spacing + bw / 2) + (channelNum - 1) * channelSpacing
+ }
} else {
0f
}
From 04abfecf705bfc6101ec7b618b0918f6214029b2 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Fri, 23 Jan 2026 11:29:14 -0600
Subject: [PATCH 4/5] remove us 433
---
.../org/meshtastic/core/model/ChannelOption.kt | 17 +----------------
1 file changed, 1 insertion(+), 16 deletions(-)
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/ChannelOption.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/ChannelOption.kt
index fdd708d8a3..14bad5fdde 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/ChannelOption.kt
+++ b/core/model/src/main/kotlin/org/meshtastic/core/model/ChannelOption.kt
@@ -101,14 +101,9 @@ internal fun LoRaConfig.radioFreq(channelNum: Int): Float {
val channelSpacing = regionInfo.spacing + bw
// The frequency calculation attempts to match firmware behavior,
// where channel_num is 0-indexed in the calculation, but 1-indexed in the app's channelNum function.
- // For amateur radio regions, regionInfo.spacing is not included in the frequency calculation.
// Firmware example: float freq = myRegion->freqStart + myRegion->spacing + (bw / 2000) + (channel_num *
// channelSpacing);
- if (regionInfo.description.contains("Amateur")) {
- (regionInfo.freqStart + bw / 2) + (channelNum - 1) * channelSpacing
- } else {
- (regionInfo.freqStart + regionInfo.spacing + bw / 2) + (channelNum - 1) * channelSpacing
- }
+ (regionInfo.freqStart + regionInfo.spacing + bw / 2) + (channelNum - 1) * channelSpacing
} else {
0f
}
@@ -344,16 +339,6 @@ enum class RegionInfo(
spacing = 0.015f,
defaultPreset = ModemPreset.NARROW_FAST,
),
-
- /** US 433MHz Amateur Use band */
- HAM_US433(
- RegionCode.HAM_US433,
- "United States 433MHz (Amateur)",
- 430.0f,
- 450.0f,
- wideLora = false,
- defaultPreset = ModemPreset.NARROW_SLOW,
- ),
;
companion object {
From c3be3f2b33c8c7d042b7115db68e7aeceb5759f5 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Fri, 23 Jan 2026 13:38:06 -0600
Subject: [PATCH 5/5] checkout protos from tom's PR
---
core/proto/src/main/proto | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/core/proto/src/main/proto b/core/proto/src/main/proto
index 77c8329a59..a7c51f5743 160000
--- a/core/proto/src/main/proto
+++ b/core/proto/src/main/proto
@@ -1 +1 @@
-Subproject commit 77c8329a59a9c96a61c447b5d5f1a52ca583e4f2
+Subproject commit a7c51f5743bff9d5e6ef7ae3ddcac37201edc5ec