diff --git a/app/src/main/java/network/columba/app/data/model/RNodeRegionalPreset.kt b/app/src/main/java/network/columba/app/data/model/RNodeRegionalPreset.kt index dd737a407..cd1e79670 100644 --- a/app/src/main/java/network/columba/app/data/model/RNodeRegionalPreset.kt +++ b/app/src/main/java/network/columba/app/data/model/RNodeRegionalPreset.kt @@ -79,8 +79,21 @@ data class RNodeRegionalPreset( val spreadingFactor: Int, /** LoRa CR (5-8) */ val codingRate: Int, - /** Transmission power in dBm */ - val txPower: Int, + /** + * Transmission power in dBm, or null when the preset carries no explicit + * override and the frequency region's default should be kept. Most RNode + * radios top out at 17-22 dBm; only the Heltec v4 reaches the higher EU868 + * sub-band-P ceiling, so presets defer to the region default unless a + * board is known to need more. Applied (when non-null) when the preset is + * selected; a null value leaves the current / region-default TX power. + */ + val txPower: Int? = null, + /** + * Long-term airtime limit in percent (1-100), or null when the preset + * carries no explicit long-term airtime constraint. Applied to the + * `lt_alock` field when the preset is selected. + */ + val longTermAirtimeLimit: Int? = null, val description: String, ) @@ -796,6 +809,18 @@ object RNodeRegionalPresets { txPower = 14, description = "Wiesbaden configuration", ), + RNodeRegionalPreset( + id = "de_ruhrgebiet", + countryCode = "DE", + countryName = "Germany", + cityOrRegion = "Ruhrgebiet", + frequency = 869462500, + bandwidth = 125000, + spreadingFactor = 8, + codingRate = 5, + longTermAirtimeLimit = 10, + description = "Ruhrgebiet configuration (869.4625 MHz, region-default TX, 10% LT airtime)", + ), // ==================== ITALY ==================== RNodeRegionalPreset( id = "it_default", diff --git a/app/src/main/java/network/columba/app/ui/screens/rnode/RegionSelectionStep.kt b/app/src/main/java/network/columba/app/ui/screens/rnode/RegionSelectionStep.kt index a6f51c91a..4e881432e 100644 --- a/app/src/main/java/network/columba/app/ui/screens/rnode/RegionSelectionStep.kt +++ b/app/src/main/java/network/columba/app/ui/screens/rnode/RegionSelectionStep.kt @@ -2,6 +2,8 @@ package network.columba.app.ui.screens.rnode import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -343,6 +345,7 @@ private fun CountryCard( } } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun PopularPresetCard( preset: RNodeRegionalPreset, @@ -405,8 +408,9 @@ private fun PopularPresetCard( Spacer(Modifier.height(8.dp)) // Settings preview - show all parameters since these are complete presets - Row( + FlowRow( horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { SettingChip( label = "${preset.frequency / 1_000_000.0} MHz", @@ -420,10 +424,18 @@ private fun PopularPresetCard( label = "${preset.bandwidth / 1000} kHz", isSelected = isSelected, ) - SettingChip( - label = "${preset.txPower} dBm", - isSelected = isSelected, - ) + preset.txPower?.let { tx -> + SettingChip( + label = "$tx dBm", + isSelected = isSelected, + ) + } + preset.longTermAirtimeLimit?.let { limit -> + SettingChip( + label = "${limit}% LT", + isSelected = isSelected, + ) + } } } } diff --git a/app/src/main/java/network/columba/app/ui/screens/rnode/ReviewConfigStep.kt b/app/src/main/java/network/columba/app/ui/screens/rnode/ReviewConfigStep.kt index d196d59c2..3d63a38dc 100644 --- a/app/src/main/java/network/columba/app/ui/screens/rnode/ReviewConfigStep.kt +++ b/app/src/main/java/network/columba/app/ui/screens/rnode/ReviewConfigStep.kt @@ -398,7 +398,7 @@ fun ReviewConfigStep(viewModel: RNodeWizardViewModel) { Spacer(Modifier.height(8.dp)) // SF, CR, TX Power row - val maxTxPower = regionLimits?.maxTxPower ?: 22 + val maxTxPower = regionLimits?.maxTxPower ?: 27 Row( horizontalArrangement = Arrangement.spacedBy(8.dp), diff --git a/app/src/main/java/network/columba/app/viewmodel/RNodeConfigValidator.kt b/app/src/main/java/network/columba/app/viewmodel/RNodeConfigValidator.kt index d27c42593..b1286de5a 100644 --- a/app/src/main/java/network/columba/app/viewmodel/RNodeConfigValidator.kt +++ b/app/src/main/java/network/columba/app/viewmodel/RNodeConfigValidator.kt @@ -54,7 +54,7 @@ object RNodeConfigValidator { private const val MIN_CR = 5 private const val MAX_CR = 8 private const val MIN_TX_POWER = 0 - private const val DEFAULT_MAX_TX_POWER = 22 + private const val DEFAULT_MAX_TX_POWER = 27 // Default frequency range (when no region is selected) private const val DEFAULT_MIN_FREQ = 137_000_000L diff --git a/app/src/main/java/network/columba/app/viewmodel/RNodeWizardViewModel.kt b/app/src/main/java/network/columba/app/viewmodel/RNodeWizardViewModel.kt index b671af715..f4b9b92ad 100644 --- a/app/src/main/java/network/columba/app/viewmodel/RNodeWizardViewModel.kt +++ b/app/src/main/java/network/columba/app/viewmodel/RNodeWizardViewModel.kt @@ -3167,6 +3167,8 @@ class RNodeWizardViewModel } fun selectPreset(preset: RNodeRegionalPreset) { + val ltAlock = preset.longTermAirtimeLimit?.toString() + val presetTx = preset.txPower?.toString() _state.update { it.copy( selectedPreset = preset, @@ -3180,8 +3182,15 @@ class RNodeWizardViewModel spreadingFactorError = null, codingRate = preset.codingRate.toString(), codingRateError = null, - txPower = preset.txPower.toString(), - txPowerError = null, + // Presets without an explicit TX power keep the current value + // (the region default), since most RNode radios top out at + // 17-22 dBm and only the Heltec v4 reaches the higher + // EU868 sub-band-P ceiling. + txPower = presetTx ?: it.txPower, + txPowerError = if (presetTx != null) null else it.txPowerError, + // Apply the preset's long-term airtime limit if it defines one + ltAlock = ltAlock ?: it.ltAlock, + ltAlockError = if (ltAlock != null) null else it.ltAlockError, ) } } diff --git a/app/src/test/java/network/columba/app/data/model/RNodeRegionalPresetsTest.kt b/app/src/test/java/network/columba/app/data/model/RNodeRegionalPresetsTest.kt index bb136de4d..9c9334d85 100644 --- a/app/src/test/java/network/columba/app/data/model/RNodeRegionalPresetsTest.kt +++ b/app/src/test/java/network/columba/app/data/model/RNodeRegionalPresetsTest.kt @@ -40,6 +40,24 @@ class RNodeRegionalPresetsTest { assertTrue("Should include United Kingdom", "United Kingdom" in countries) } + @Test + fun `Germany includes the Ruhrgebiet preset with expected values`() { + val preset = RNodeRegionalPresets.getPresetsForCountry("Germany") + .firstOrNull { it.id == "de_ruhrgebiet" } + + assertNotNull("Ruhrgebiet preset should exist for Germany", preset) + assertEquals("Ruhrgebiet", preset!!.cityOrRegion) + assertEquals(869_462_500L, preset.frequency) + assertEquals(125_000, preset.bandwidth) + assertEquals(8, preset.spreadingFactor) + assertEquals(5, preset.codingRate) + // No explicit TX power: only the Heltec v4 reaches >22 dBm, so the + // preset defers to the frequency region's default. + assertNull(preset.txPower) + // Preset carries the long-term airtime limit applied to lt_alock on selection + assertEquals(10, preset.longTermAirtimeLimit) + } + // ========== getByCountry Tests ========== @Test @@ -217,8 +235,8 @@ class RNodeRegionalPresetsTest { fun `all presets have valid TX power`() { RNodeRegionalPresets.presets.forEach { preset -> assertTrue( - "Preset ${preset.id} should have TX power 1-30, got ${preset.txPower}", - preset.txPower in 1..30, + "Preset ${preset.id} should have TX power 1-30 or none (region default), got ${preset.txPower}", + preset.txPower == null || preset.txPower in 1..30, ) } } diff --git a/app/src/test/java/network/columba/app/viewmodel/RNodeConfigValidatorTest.kt b/app/src/test/java/network/columba/app/viewmodel/RNodeConfigValidatorTest.kt index 1548eb9c6..50dc5122f 100644 --- a/app/src/test/java/network/columba/app/viewmodel/RNodeConfigValidatorTest.kt +++ b/app/src/test/java/network/columba/app/viewmodel/RNodeConfigValidatorTest.kt @@ -240,10 +240,10 @@ class RNodeConfigValidatorTest { @Test fun `validateTxPower without region uses default max`() { - // Default max is 22 - val result = RNodeConfigValidator.validateTxPower("25", null) + // Default max is 27 + val result = RNodeConfigValidator.validateTxPower("30", null) assertFalse(result.isValid) - assertTrue(result.errorMessage!!.contains("22")) + assertTrue(result.errorMessage!!.contains("27")) } @Test @@ -487,7 +487,7 @@ class RNodeConfigValidatorTest { @Test fun `getMaxTxPower returns default without region`() { - assertEquals(22, RNodeConfigValidator.getMaxTxPower(null)) + assertEquals(27, RNodeConfigValidator.getMaxTxPower(null)) } @Test diff --git a/app/src/test/java/network/columba/app/viewmodel/RNodeWizardViewModelTest.kt b/app/src/test/java/network/columba/app/viewmodel/RNodeWizardViewModelTest.kt index 462396d72..47adffaf1 100644 --- a/app/src/test/java/network/columba/app/viewmodel/RNodeWizardViewModelTest.kt +++ b/app/src/test/java/network/columba/app/viewmodel/RNodeWizardViewModelTest.kt @@ -11,6 +11,7 @@ import network.columba.app.data.model.DiscoveredUsbDevice import network.columba.app.data.model.FrequencyRegions import network.columba.app.data.model.ModemPreset import network.columba.app.data.model.RNodeRegionalPreset +import network.columba.app.data.model.RNodeRegionalPresets import network.columba.app.repository.InterfaceRepository import network.columba.app.rns.api.model.InterfaceConfig import network.columba.app.service.InterfaceConfigManager @@ -781,6 +782,82 @@ class RNodeWizardViewModelTest { } } + @Test + fun `selectPreset without explicit tx power keeps region default and applies airtime limit`() = + runViewModelTest { + advanceUntilIdle() + + viewModel.goToStep(WizardStep.REGION_SELECTION) + advanceUntilIdle() + + // EU868 sub-band P defaults to 14 dBm; the preset must not override it. + viewModel.selectFrequencyRegion(euRegionP) + advanceUntilIdle() + + val preset = RNodeRegionalPresets.presets.first { it.id == "de_ruhrgebiet" } + viewModel.selectPreset(preset) + advanceUntilIdle() + + val state = viewModel.state.value + assertEquals(preset.id, state.selectedPreset?.id) + assertEquals("869462500", state.frequency) + // No explicit TX power on the preset -> region default (14) is kept. + assertNull(preset.txPower) + assertEquals(euRegionP.defaultTxPower.toString(), state.txPower) + // Preset carries an explicit long-term airtime limit, applied to ltAlock + assertEquals("10", state.ltAlock) + } + + @Test + fun `selectPreset with explicit tx power applies it over the region default`() = + runViewModelTest { + advanceUntilIdle() + + viewModel.goToStep(WizardStep.REGION_SELECTION) + advanceUntilIdle() + + viewModel.selectFrequencyRegion(euRegionP) // default 14 dBm + advanceUntilIdle() + + val preset = + RNodeRegionalPreset( + id = "test_explicit_tx", + countryCode = "DE", + countryName = "Germany", + cityOrRegion = "Test City", + frequency = 869_462_500, + bandwidth = 125_000, + spreadingFactor = 8, + codingRate = 5, + txPower = 17, + description = "Explicit TX test preset", + ) + viewModel.selectPreset(preset) + advanceUntilIdle() + + val state = viewModel.state.value + assertEquals("17", state.txPower) + } + + @Test + fun `selectPreset without airtime limit preserves existing ltAlock`() = + runViewModelTest { + advanceUntilIdle() + + viewModel.goToStep(WizardStep.REGION_SELECTION) + advanceUntilIdle() + + viewModel.updateLtAlock("5") + advanceUntilIdle() + + val preset = RNodeRegionalPresets.presets.first { it.id == "us_default" } + viewModel.selectPreset(preset) + advanceUntilIdle() + + // Preset defines no long-term airtime limit, so the prior value is kept + assertEquals("5", viewModel.state.value.ltAlock) + } + @Test fun `updateStAlock sets error when exceeding duty cycle limit`() = runViewModelTest { diff --git a/rns-api/src/main/java/network/columba/app/rns/api/model/ReticulumConfig.kt b/rns-api/src/main/java/network/columba/app/rns/api/model/ReticulumConfig.kt index ddfa5dc83..10026a5e6 100644 --- a/rns-api/src/main/java/network/columba/app/rns/api/model/ReticulumConfig.kt +++ b/rns-api/src/main/java/network/columba/app/rns/api/model/ReticulumConfig.kt @@ -325,7 +325,7 @@ sealed class InterfaceConfig : Parcelable { * @param usbDeviceId Android USB device ID for USB serial mode (required when connectionMode="usb") * @param frequency LoRa frequency in Hz (137000000 - 3000000000) * @param bandwidth LoRa bandwidth in Hz (7800 - 1625000) - * @param txPower Transmission power in dBm (0-22) + * @param txPower Transmission power in dBm (0-27) * @param spreadingFactor LoRa spreading factor (5-12) * @param codingRate LoRa coding rate (5-8) * @param stAlock Short-term airtime limit percentage (optional)