Skip to content

Commit b6ed144

Browse files
Merge pull request #1131 from torlando-tech/feature/rnode-ruhrgebiet-preset
RNode: add Germany Ruhrgebiet preset and raise custom TX power cap to 27 dBm
2 parents cffa02a + 047e53e commit b6ed144

9 files changed

Lines changed: 159 additions & 18 deletions

File tree

app/src/main/java/network/columba/app/data/model/RNodeRegionalPreset.kt

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,21 @@ data class RNodeRegionalPreset(
7979
val spreadingFactor: Int,
8080
/** LoRa CR (5-8) */
8181
val codingRate: Int,
82-
/** Transmission power in dBm */
83-
val txPower: Int,
82+
/**
83+
* Transmission power in dBm, or null when the preset carries no explicit
84+
* override and the frequency region's default should be kept. Most RNode
85+
* radios top out at 17-22 dBm; only the Heltec v4 reaches the higher EU868
86+
* sub-band-P ceiling, so presets defer to the region default unless a
87+
* board is known to need more. Applied (when non-null) when the preset is
88+
* selected; a null value leaves the current / region-default TX power.
89+
*/
90+
val txPower: Int? = null,
91+
/**
92+
* Long-term airtime limit in percent (1-100), or null when the preset
93+
* carries no explicit long-term airtime constraint. Applied to the
94+
* `lt_alock` field when the preset is selected.
95+
*/
96+
val longTermAirtimeLimit: Int? = null,
8497
val description: String,
8598
)
8699

@@ -796,6 +809,18 @@ object RNodeRegionalPresets {
796809
txPower = 14,
797810
description = "Wiesbaden configuration",
798811
),
812+
RNodeRegionalPreset(
813+
id = "de_ruhrgebiet",
814+
countryCode = "DE",
815+
countryName = "Germany",
816+
cityOrRegion = "Ruhrgebiet",
817+
frequency = 869462500,
818+
bandwidth = 125000,
819+
spreadingFactor = 8,
820+
codingRate = 5,
821+
longTermAirtimeLimit = 10,
822+
description = "Ruhrgebiet configuration (869.4625 MHz, region-default TX, 10% LT airtime)",
823+
),
799824
// ==================== ITALY ====================
800825
RNodeRegionalPreset(
801826
id = "it_default",

app/src/main/java/network/columba/app/ui/screens/rnode/RegionSelectionStep.kt

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package network.columba.app.ui.screens.rnode
22

33
import androidx.compose.foundation.layout.Arrangement
44
import androidx.compose.foundation.layout.Column
5+
import androidx.compose.foundation.layout.ExperimentalLayoutApi
6+
import androidx.compose.foundation.layout.FlowRow
57
import androidx.compose.foundation.layout.Row
68
import androidx.compose.foundation.layout.Spacer
79
import androidx.compose.foundation.layout.fillMaxSize
@@ -343,6 +345,7 @@ private fun CountryCard(
343345
}
344346
}
345347

348+
@OptIn(ExperimentalLayoutApi::class)
346349
@Composable
347350
private fun PopularPresetCard(
348351
preset: RNodeRegionalPreset,
@@ -405,8 +408,9 @@ private fun PopularPresetCard(
405408
Spacer(Modifier.height(8.dp))
406409

407410
// Settings preview - show all parameters since these are complete presets
408-
Row(
411+
FlowRow(
409412
horizontalArrangement = Arrangement.spacedBy(16.dp),
413+
verticalArrangement = Arrangement.spacedBy(4.dp),
410414
) {
411415
SettingChip(
412416
label = "${preset.frequency / 1_000_000.0} MHz",
@@ -420,10 +424,18 @@ private fun PopularPresetCard(
420424
label = "${preset.bandwidth / 1000} kHz",
421425
isSelected = isSelected,
422426
)
423-
SettingChip(
424-
label = "${preset.txPower} dBm",
425-
isSelected = isSelected,
426-
)
427+
preset.txPower?.let { tx ->
428+
SettingChip(
429+
label = "$tx dBm",
430+
isSelected = isSelected,
431+
)
432+
}
433+
preset.longTermAirtimeLimit?.let { limit ->
434+
SettingChip(
435+
label = "${limit}% LT",
436+
isSelected = isSelected,
437+
)
438+
}
427439
}
428440
}
429441
}

app/src/main/java/network/columba/app/ui/screens/rnode/ReviewConfigStep.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ fun ReviewConfigStep(viewModel: RNodeWizardViewModel) {
398398
Spacer(Modifier.height(8.dp))
399399

400400
// SF, CR, TX Power row
401-
val maxTxPower = regionLimits?.maxTxPower ?: 22
401+
val maxTxPower = regionLimits?.maxTxPower ?: 27
402402

403403
Row(
404404
horizontalArrangement = Arrangement.spacedBy(8.dp),

app/src/main/java/network/columba/app/viewmodel/RNodeConfigValidator.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ object RNodeConfigValidator {
5454
private const val MIN_CR = 5
5555
private const val MAX_CR = 8
5656
private const val MIN_TX_POWER = 0
57-
private const val DEFAULT_MAX_TX_POWER = 22
57+
private const val DEFAULT_MAX_TX_POWER = 27
5858

5959
// Default frequency range (when no region is selected)
6060
private const val DEFAULT_MIN_FREQ = 137_000_000L

app/src/main/java/network/columba/app/viewmodel/RNodeWizardViewModel.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3311,6 +3311,8 @@ class RNodeWizardViewModel
33113311
}
33123312

33133313
fun selectPreset(preset: RNodeRegionalPreset) {
3314+
val ltAlock = preset.longTermAirtimeLimit?.toString()
3315+
val presetTx = preset.txPower?.toString()
33143316
_state.update {
33153317
it.copy(
33163318
selectedPreset = preset,
@@ -3324,8 +3326,15 @@ class RNodeWizardViewModel
33243326
spreadingFactorError = null,
33253327
codingRate = preset.codingRate.toString(),
33263328
codingRateError = null,
3327-
txPower = preset.txPower.toString(),
3328-
txPowerError = null,
3329+
// Presets without an explicit TX power keep the current value
3330+
// (the region default), since most RNode radios top out at
3331+
// 17-22 dBm and only the Heltec v4 reaches the higher
3332+
// EU868 sub-band-P ceiling.
3333+
txPower = presetTx ?: it.txPower,
3334+
txPowerError = if (presetTx != null) null else it.txPowerError,
3335+
// Apply the preset's long-term airtime limit if it defines one
3336+
ltAlock = ltAlock ?: it.ltAlock,
3337+
ltAlockError = if (ltAlock != null) null else it.ltAlockError,
33293338
)
33303339
}
33313340
}

app/src/test/java/network/columba/app/data/model/RNodeRegionalPresetsTest.kt

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,24 @@ class RNodeRegionalPresetsTest {
4040
assertTrue("Should include United Kingdom", "United Kingdom" in countries)
4141
}
4242

43+
@Test
44+
fun `Germany includes the Ruhrgebiet preset with expected values`() {
45+
val preset = RNodeRegionalPresets.getPresetsForCountry("Germany")
46+
.firstOrNull { it.id == "de_ruhrgebiet" }
47+
48+
assertNotNull("Ruhrgebiet preset should exist for Germany", preset)
49+
assertEquals("Ruhrgebiet", preset!!.cityOrRegion)
50+
assertEquals(869_462_500L, preset.frequency)
51+
assertEquals(125_000, preset.bandwidth)
52+
assertEquals(8, preset.spreadingFactor)
53+
assertEquals(5, preset.codingRate)
54+
// No explicit TX power: only the Heltec v4 reaches >22 dBm, so the
55+
// preset defers to the frequency region's default.
56+
assertNull(preset.txPower)
57+
// Preset carries the long-term airtime limit applied to lt_alock on selection
58+
assertEquals(10, preset.longTermAirtimeLimit)
59+
}
60+
4361
// ========== getByCountry Tests ==========
4462

4563
@Test
@@ -217,8 +235,8 @@ class RNodeRegionalPresetsTest {
217235
fun `all presets have valid TX power`() {
218236
RNodeRegionalPresets.presets.forEach { preset ->
219237
assertTrue(
220-
"Preset ${preset.id} should have TX power 1-30, got ${preset.txPower}",
221-
preset.txPower in 1..30,
238+
"Preset ${preset.id} should have TX power 1-30 or none (region default), got ${preset.txPower}",
239+
preset.txPower == null || preset.txPower in 1..30,
222240
)
223241
}
224242
}

app/src/test/java/network/columba/app/viewmodel/RNodeConfigValidatorTest.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -240,10 +240,10 @@ class RNodeConfigValidatorTest {
240240

241241
@Test
242242
fun `validateTxPower without region uses default max`() {
243-
// Default max is 22
244-
val result = RNodeConfigValidator.validateTxPower("25", null)
243+
// Default max is 27
244+
val result = RNodeConfigValidator.validateTxPower("30", null)
245245
assertFalse(result.isValid)
246-
assertTrue(result.errorMessage!!.contains("22"))
246+
assertTrue(result.errorMessage!!.contains("27"))
247247
}
248248

249249
@Test
@@ -487,7 +487,7 @@ class RNodeConfigValidatorTest {
487487

488488
@Test
489489
fun `getMaxTxPower returns default without region`() {
490-
assertEquals(22, RNodeConfigValidator.getMaxTxPower(null))
490+
assertEquals(27, RNodeConfigValidator.getMaxTxPower(null))
491491
}
492492

493493
@Test

app/src/test/java/network/columba/app/viewmodel/RNodeWizardViewModelTest.kt

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import network.columba.app.data.model.DiscoveredUsbDevice
1212
import network.columba.app.data.model.FrequencyRegions
1313
import network.columba.app.data.model.ModemPreset
1414
import network.columba.app.data.model.RNodeRegionalPreset
15+
import network.columba.app.data.model.RNodeRegionalPresets
1516
import network.columba.app.repository.InterfaceRepository
1617
import network.columba.app.rns.api.model.InterfaceConfig
1718
import network.columba.app.service.InterfaceConfigManager
@@ -785,6 +786,82 @@ class RNodeWizardViewModelTest {
785786
}
786787
}
787788

789+
@Test
790+
fun `selectPreset without explicit tx power keeps region default and applies airtime limit`() =
791+
runViewModelTest {
792+
advanceUntilIdle()
793+
794+
viewModel.goToStep(WizardStep.REGION_SELECTION)
795+
advanceUntilIdle()
796+
797+
// EU868 sub-band P defaults to 14 dBm; the preset must not override it.
798+
viewModel.selectFrequencyRegion(euRegionP)
799+
advanceUntilIdle()
800+
801+
val preset = RNodeRegionalPresets.presets.first { it.id == "de_ruhrgebiet" }
802+
viewModel.selectPreset(preset)
803+
advanceUntilIdle()
804+
805+
val state = viewModel.state.value
806+
assertEquals(preset.id, state.selectedPreset?.id)
807+
assertEquals("869462500", state.frequency)
808+
// No explicit TX power on the preset -> region default (14) is kept.
809+
assertNull(preset.txPower)
810+
assertEquals(euRegionP.defaultTxPower.toString(), state.txPower)
811+
// Preset carries an explicit long-term airtime limit, applied to ltAlock
812+
assertEquals("10", state.ltAlock)
813+
}
814+
815+
@Test
816+
fun `selectPreset with explicit tx power applies it over the region default`() =
817+
runViewModelTest {
818+
advanceUntilIdle()
819+
820+
viewModel.goToStep(WizardStep.REGION_SELECTION)
821+
advanceUntilIdle()
822+
823+
viewModel.selectFrequencyRegion(euRegionP) // default 14 dBm
824+
advanceUntilIdle()
825+
826+
val preset =
827+
RNodeRegionalPreset(
828+
id = "test_explicit_tx",
829+
countryCode = "DE",
830+
countryName = "Germany",
831+
cityOrRegion = "Test City",
832+
frequency = 869_462_500,
833+
bandwidth = 125_000,
834+
spreadingFactor = 8,
835+
codingRate = 5,
836+
txPower = 17,
837+
description = "Explicit TX test preset",
838+
)
839+
viewModel.selectPreset(preset)
840+
advanceUntilIdle()
841+
842+
val state = viewModel.state.value
843+
assertEquals("17", state.txPower)
844+
}
845+
846+
@Test
847+
fun `selectPreset without airtime limit preserves existing ltAlock`() =
848+
runViewModelTest {
849+
advanceUntilIdle()
850+
851+
viewModel.goToStep(WizardStep.REGION_SELECTION)
852+
advanceUntilIdle()
853+
854+
viewModel.updateLtAlock("5")
855+
advanceUntilIdle()
856+
857+
val preset = RNodeRegionalPresets.presets.first { it.id == "us_default" }
858+
viewModel.selectPreset(preset)
859+
advanceUntilIdle()
860+
861+
// Preset defines no long-term airtime limit, so the prior value is kept
862+
assertEquals("5", viewModel.state.value.ltAlock)
863+
}
864+
788865
@Test
789866
fun `updateStAlock sets error when exceeding duty cycle limit`() =
790867
runViewModelTest {

rns-api/src/main/java/network/columba/app/rns/api/model/ReticulumConfig.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ sealed class InterfaceConfig : Parcelable {
340340
* @param usbDeviceId Android USB device ID for USB serial mode (required when connectionMode="usb")
341341
* @param frequency LoRa frequency in Hz (137000000 - 3000000000)
342342
* @param bandwidth LoRa bandwidth in Hz (7800 - 1625000)
343-
* @param txPower Transmission power in dBm (0-22)
343+
* @param txPower Transmission power in dBm (0-27)
344344
* @param spreadingFactor LoRa spreading factor (5-12)
345345
* @param codingRate LoRa coding rate (5-8)
346346
* @param stAlock Short-term airtime limit percentage (optional)

0 commit comments

Comments
 (0)