From 37b2efa176b3f34e5eb76254f6b9964eb47ea78e Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 2 Feb 2026 09:35:20 -0600 Subject: [PATCH] feat(settings): Add firmware capability checks for settings (#4403) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../org/meshtastic/core/model/Capabilities.kt | 8 +++ .../meshtastic/core/model/CapabilitiesTest.kt | 20 +++++++ .../settings/navigation/ModuleRoute.kt | 29 +++++---- .../radio/channel/ChannelConfigScreen.kt | 19 +++--- .../radio/channel/component/ChannelLegend.kt | 60 ++++++++----------- 5 files changed, 76 insertions(+), 60 deletions(-) diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/Capabilities.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/Capabilities.kt index cde571951..3d481cd3c 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/Capabilities.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/Capabilities.kt @@ -56,6 +56,14 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl val supportsQrCodeSharing: Boolean get() = isSupported("2.6.8") + /** Support for Status Message module. Supported since firmware v2.7.17. */ + val supportsStatusMessage: Boolean + get() = isSupported("2.7.17") + + /** Support for location sharing on secondary channels. Supported since firmware v2.6.10. */ + val supportsSecondaryChannelLocation: Boolean + get() = isSupported("2.6.10") + /** Support for ESP32 Unified OTA. Supported since firmware v2.7.18. */ val supportsEsp32Ota: Boolean get() = isSupported("2.7.18") diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt b/core/model/src/test/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt index 6e77cc6a8..e1ffb313a 100644 --- a/core/model/src/test/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt +++ b/core/model/src/test/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt @@ -65,6 +65,18 @@ class CapabilitiesTest { assertTrue(caps("2.6.8").supportsQrCodeSharing) } + @Test + fun `supportsSecondaryChannelLocation requires v2 6 10`() { + assertFalse(caps("2.6.9").supportsSecondaryChannelLocation) + assertTrue(caps("2.6.10").supportsSecondaryChannelLocation) + } + + @Test + fun `supportsStatusMessage requires v2 7 17`() { + assertFalse(caps("2.7.16").supportsStatusMessage) + assertTrue(caps("2.7.17").supportsStatusMessage) + } + @Test fun `null firmware returns all false`() { val c = caps(null) @@ -74,6 +86,8 @@ class CapabilitiesTest { assertFalse(c.canToggleTelemetryEnabled) assertFalse(c.canToggleUnmessageable) assertFalse(c.supportsQrCodeSharing) + assertFalse(c.supportsSecondaryChannelLocation) + assertFalse(c.supportsStatusMessage) } @Test @@ -85,6 +99,8 @@ class CapabilitiesTest { assertFalse(c.canToggleTelemetryEnabled) assertFalse(c.canToggleUnmessageable) assertFalse(c.supportsQrCodeSharing) + assertFalse(c.supportsSecondaryChannelLocation) + assertFalse(c.supportsStatusMessage) } @Test @@ -96,6 +112,8 @@ class CapabilitiesTest { assertTrue(c.canToggleTelemetryEnabled) assertTrue(c.canToggleUnmessageable) assertTrue(c.supportsQrCodeSharing) + assertTrue(c.supportsSecondaryChannelLocation) + assertTrue(c.supportsStatusMessage) } @Test @@ -107,5 +125,7 @@ class CapabilitiesTest { assertTrue(c.canToggleTelemetryEnabled) assertTrue(c.canToggleUnmessageable) assertTrue(c.supportsQrCodeSharing) + assertTrue(c.supportsSecondaryChannelLocation) + assertTrue(c.supportsStatusMessage) } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt index 8ad211c6f..981fc75de 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt @@ -20,16 +20,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Forward import androidx.compose.material.icons.automirrored.filled.Message import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.filled.Cloud -import androidx.compose.material.icons.filled.DataUsage -import androidx.compose.material.icons.filled.LightMode -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material.icons.filled.People -import androidx.compose.material.icons.filled.PermScanWifi -import androidx.compose.material.icons.filled.Sensors -import androidx.compose.material.icons.filled.SettingsRemote -import androidx.compose.material.icons.filled.Speed -import androidx.compose.material.icons.filled.Usb import androidx.compose.material.icons.rounded.Cloud import androidx.compose.material.icons.rounded.DataUsage import androidx.compose.material.icons.rounded.LightMode @@ -42,6 +32,7 @@ import androidx.compose.material.icons.rounded.Speed import androidx.compose.material.icons.rounded.Usb import androidx.compose.ui.graphics.vector.ImageVector import org.jetbrains.compose.resources.StringResource +import org.meshtastic.core.model.Capabilities import org.meshtastic.core.navigation.Route import org.meshtastic.core.navigation.SettingsRoutes import org.meshtastic.core.strings.Res @@ -62,7 +53,13 @@ import org.meshtastic.core.strings.telemetry import org.meshtastic.proto.AdminProtos import org.meshtastic.proto.MeshProtos.DeviceMetadata -enum class ModuleRoute(val title: StringResource, val route: Route, val icon: ImageVector?, val type: Int = 0) { +enum class ModuleRoute( + val title: StringResource, + val route: Route, + val icon: ImageVector?, + val type: Int = 0, + val isSupported: (Capabilities) -> Boolean = { true }, +) { MQTT( Res.string.mqtt, SettingsRoutes.MQTT, @@ -146,6 +143,7 @@ enum class ModuleRoute(val title: StringResource, val route: Route, val icon: Im SettingsRoutes.StatusMessage, Icons.AutoMirrored.Default.Message, AdminProtos.AdminMessage.ModuleConfigType.STATUSMESSAGE_CONFIG_VALUE, + isSupported = { it.supportsStatusMessage }, ), ; @@ -153,10 +151,11 @@ enum class ModuleRoute(val title: StringResource, val route: Route, val icon: Im get() = 1 shl ordinal companion object { - fun filterExcludedFrom(metadata: DeviceMetadata?): List = entries.filter { - when (metadata) { - null -> true // Include all routes if metadata is null - else -> metadata.excludedModules and it.bitfield == 0 + fun filterExcludedFrom(metadata: DeviceMetadata?): List { + val capabilities = Capabilities(metadata?.firmwareVersion) + return entries.filter { + val isExcluded = metadata != null && (metadata.excludedModules and it.bitfield != 0) + !isExcluded && it.isSupported(capabilities) } } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt index 1e20496fc..63d42ea5d 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.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.channel import androidx.compose.animation.AnimatedVisibility @@ -53,8 +52,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.Capabilities import org.meshtastic.core.model.Channel -import org.meshtastic.core.model.DeviceVersion import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.add import org.meshtastic.core.strings.cancel @@ -73,7 +72,6 @@ import org.meshtastic.feature.settings.radio.channel.component.ChannelConfigHead import org.meshtastic.feature.settings.radio.channel.component.ChannelLegend import org.meshtastic.feature.settings.radio.channel.component.ChannelLegendDialog import org.meshtastic.feature.settings.radio.channel.component.EditChannelDialog -import org.meshtastic.feature.settings.radio.channel.component.SECONDARY_CHANNEL_EPOCH import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog import org.meshtastic.proto.ChannelProtos.ChannelSettings import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig @@ -114,8 +112,7 @@ private fun ChannelConfigScreen( val primarySettings = settingsList.getOrNull(0) ?: return val modemPresetName by remember(loraConfig) { mutableStateOf(Channel(loraConfig = loraConfig).name) } val primaryChannel by remember(loraConfig) { mutableStateOf(Channel(primarySettings, loraConfig)) } - val fwVersion by - remember(firmwareVersion) { mutableStateOf(DeviceVersion(firmwareVersion.substringBeforeLast("."))) } + val capabilities by remember(firmwareVersion) { mutableStateOf(Capabilities(firmwareVersion)) } val focusManager = LocalFocusManager.current val settingsListInput = @@ -156,7 +153,7 @@ private fun ChannelConfigScreen( } if (showChannelLegendDialog) { - ChannelLegendDialog(fwVersion) { showChannelLegendDialog = false } + ChannelLegendDialog(capabilities) { showChannelLegendDialog = false } } Scaffold( @@ -211,7 +208,7 @@ private fun ChannelConfigScreen( ChannelLegend { showChannelLegendDialog = true } - val locationChannel = determineLocationSharingChannel(fwVersion, settingsListInput.toList()) + val locationChannel = determineLocationSharingChannel(capabilities, settingsListInput.toList()) LazyColumn( modifier = @@ -276,13 +273,13 @@ private fun ChannelConfigScreen( /** * Determines what [Channel] if any is enabled to conduct automatic location sharing. * - * @param firmwareVersion of the connected node. + * @param capabilities of the connected node. * @param settingsList Current list of channels on the node. * @return the index of the channel within `settingsList`. */ -private fun determineLocationSharingChannel(firmwareVersion: DeviceVersion, settingsList: List): Int { +private fun determineLocationSharingChannel(capabilities: Capabilities, settingsList: List): Int { var output = -1 - if (firmwareVersion >= DeviceVersion(asString = SECONDARY_CHANNEL_EPOCH)) { + if (capabilities.supportsSecondaryChannelLocation) { /* Essentially the first index with the setting enabled */ for ((i, settings) in settingsList.withIndex()) { if (settings.moduleSettings.positionPrecision > 0) { diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/component/ChannelLegend.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/component/ChannelLegend.kt index 20c0d3535..6a6c78cc8 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/component/ChannelLegend.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/component/ChannelLegend.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.channel.component import androidx.compose.foundation.clickable @@ -45,7 +44,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.model.DeviceVersion +import org.meshtastic.core.model.Capabilities import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.channel_features import org.meshtastic.core.strings.downlink_enabled @@ -64,12 +63,27 @@ import org.meshtastic.core.strings.security_icon_help_dismiss import org.meshtastic.core.strings.uplink_enabled import org.meshtastic.core.strings.uplink_feature_description -/** - * At this firmware version periodic position sharing on a secondary channel was implemented. To enable this feature the - * user must disable position on the primary channel and enable on a secondary channel. The lowest indexed secondary - * channel with the position enabled will conduct the automatic position broadcasts. - */ -internal const val SECONDARY_CHANNEL_EPOCH = "2.6.10" +@Composable +internal fun ChannelLegend(onClick: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().clickable { onClick.invoke() }, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Row { + Icon(imageVector = Icons.Filled.Info, contentDescription = stringResource(Res.string.info)) + Text( + text = stringResource(Res.string.primary), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp), + ) + } + Text( + text = stringResource(Res.string.secondary), + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(start = 16.dp), + ) + } +} internal enum class ChannelIcons( val icon: ImageVector, @@ -94,29 +108,7 @@ internal enum class ChannelIcons( } @Composable -internal fun ChannelLegend(onClick: () -> Unit) { - Row( - modifier = Modifier.fillMaxWidth().clickable { onClick.invoke() }, - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - Row { - Icon(imageVector = Icons.Filled.Info, contentDescription = stringResource(Res.string.info)) - Text( - text = stringResource(Res.string.primary), - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 16.dp), - ) - } - Text( - text = stringResource(Res.string.secondary), - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(start = 16.dp), - ) - } -} - -@Composable -internal fun ChannelLegendDialog(firmwareVersion: DeviceVersion, onDismiss: () -> Unit) { +internal fun ChannelLegendDialog(capabilities: Capabilities, onDismiss: () -> Unit) { AlertDialog( modifier = Modifier.fillMaxSize(), onDismissRequest = onDismiss, @@ -148,7 +140,7 @@ internal fun ChannelLegendDialog(firmwareVersion: DeviceVersion, onDismiss: () - ) Text( text = - if (firmwareVersion >= DeviceVersion(asString = SECONDARY_CHANNEL_EPOCH)) { + if (capabilities.supportsSecondaryChannelLocation) { /* 2.6.10+ */ "- ${stringResource(Res.string.secondary_channel_position_feature)}" } else { @@ -192,5 +184,5 @@ private fun IconDefinitions() { @Preview @Composable private fun PreviewChannelLegendDialog() { - ChannelLegendDialog(firmwareVersion = DeviceVersion(asString = SECONDARY_CHANNEL_EPOCH)) {} + ChannelLegendDialog(capabilities = Capabilities("2.6.10")) {} }