feat(wire): migrate from protobuf -> wire (#4401)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-03 18:01:12 -06:00 committed by GitHub
parent 9dbc8b7fbf
commit 25657e8f8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
239 changed files with 7149 additions and 6144 deletions

View file

@ -1,33 +1,48 @@
<?xml version="1.0" ?>
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>CyclomaticComplexMethod:NetworkConfigItemList.kt$@Composable fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>CyclomaticComplexMethod:PositionConfigItemList.kt$@OptIn(ExperimentalPermissionsApi::class) @Composable fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>CyclomaticComplexMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket)</ID>
<ID>LongMethod:AudioConfigItemList.kt$@Composable fun AudioConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:CannedMessageConfigItemList.kt$@Composable fun CannedMessageConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:DetectionSensorConfigItemList.kt$@Composable fun DetectionSensorConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:DeviceConfigItemList.kt$@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:DisplayConfigItemList.kt$@Composable fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:LoRaConfigItemList.kt$@Composable fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -&gt; Unit)</ID>
<ID>LongMethod:NetworkConfigItemList.kt$@Composable fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:PositionConfigItemList.kt$@OptIn(ExperimentalPermissionsApi::class) @Composable fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:PowerConfigItemList.kt$@Composable fun PowerConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket)</ID>
<ID>LongMethod:SecurityConfigItemList.kt$@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:SerialConfigItemList.kt$@Composable fun SerialConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:StoreForwardConfigItemList.kt$@Composable fun StoreForwardConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:TelemetryConfigItemList.kt$@Composable fun TelemetryConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>LongMethod:UserConfigItemList.kt$@Composable fun UserConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -&gt; Unit)</ID>
<ID>CyclomaticComplexMethod:DisplayConfigItemList.kt$@Composable fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)</ID>
<ID>CyclomaticComplexMethod:EditDeviceProfileDialog.kt$@Suppress("LongMethod") @OptIn(ExperimentalLayoutApi::class) @Composable fun EditDeviceProfileDialog( title: String, deviceProfile: DeviceProfile, onConfirm: (DeviceProfile) -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, )</ID>
<ID>CyclomaticComplexMethod:ExternalNotificationConfigItemList.kt$@Suppress("LongMethod", "TooGenericExceptionCaught") @Composable fun ExternalNotificationConfigScreen( onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: RadioConfigViewModel = hiltViewModel(), )</ID>
<ID>CyclomaticComplexMethod:MQTTConfigItemList.kt$@Composable fun MQTTConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)</ID>
<ID>CyclomaticComplexMethod:NetworkConfigItemList.kt$@Composable fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)</ID>
<ID>CyclomaticComplexMethod:PositionConfigItemList.kt$@OptIn(ExperimentalPermissionsApi::class) @Composable fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)</ID>
<ID>CyclomaticComplexMethod:RadioConfigViewModel.kt$RadioConfigViewModel$fun installProfile(protobuf: DeviceProfile)</ID>
<ID>CyclomaticComplexMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshPacket)</ID>
<ID>CyclomaticComplexMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun setRemoteModuleConfig(destNum: Int, config: ModuleConfig)</ID>
<ID>CyclomaticComplexMethod:SecurityConfigItemList.kt$@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)</ID>
<ID>LargeClass:RadioConfigViewModel.kt$RadioConfigViewModel : ViewModel</ID>
<ID>LongMethod:AudioConfigItemList.kt$@Composable fun AudioConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)</ID>
<ID>LongMethod:CannedMessageConfigItemList.kt$@Composable fun CannedMessageConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)</ID>
<ID>LongMethod:DetectionSensorConfigItemList.kt$@Composable fun DetectionSensorConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)</ID>
<ID>LongMethod:DeviceConfigItemList.kt$@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)</ID>
<ID>LongMethod:DisplayConfigItemList.kt$@Composable fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)</ID>
<ID>LongMethod:LoRaConfigItemList.kt$@Composable fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)</ID>
<ID>LongMethod:NetworkConfigItemList.kt$@Composable fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)</ID>
<ID>LongMethod:PositionConfigItemList.kt$@OptIn(ExperimentalPermissionsApi::class) @Composable fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)</ID>
<ID>LongMethod:PowerConfigItemList.kt$@Composable fun PowerConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)</ID>
<ID>LongMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshPacket)</ID>
<ID>LongMethod:SecurityConfigItemList.kt$@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)</ID>
<ID>LongMethod:SerialConfigItemList.kt$@Composable fun SerialConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)</ID>
<ID>LongMethod:StoreForwardConfigItemList.kt$@Composable fun StoreForwardConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)</ID>
<ID>LongMethod:TelemetryConfigItemList.kt$@Composable fun TelemetryConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)</ID>
<ID>LongMethod:UserConfigItemList.kt$@Composable fun UserConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit)</ID>
<ID>MagicNumber:Debug.kt$3</ID>
<ID>MagicNumber:EditChannelDialog.kt$16</ID>
<ID>MagicNumber:EditChannelDialog.kt$32</ID>
<ID>MagicNumber:EditDeviceProfileDialog.kt$ProfileField.CHANNEL_URL$3</ID>
<ID>MagicNumber:EditDeviceProfileDialog.kt$ProfileField.CONFIG$4</ID>
<ID>MagicNumber:EditDeviceProfileDialog.kt$ProfileField.FIXED_POSITION$6</ID>
<ID>MagicNumber:EditDeviceProfileDialog.kt$ProfileField.MODULE_CONFIG$5</ID>
<ID>MagicNumber:PacketResponseStateDialog.kt$100</ID>
<ID>NestedBlockDepth:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket)</ID>
<ID>ReturnCount:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket)</ID>
<ID>NestedBlockDepth:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshPacket)</ID>
<ID>ReturnCount:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshPacket)</ID>
<ID>TooGenericExceptionCaught:DebugViewModel.kt$DebugViewModel$e: Exception</ID>
<ID>TooGenericExceptionCaught:LanguageUtils.kt$LanguageUtils$e: Exception</ID>
<ID>TooGenericExceptionCaught:RadioConfigViewModel.kt$RadioConfigViewModel$ex: Exception</ID>
<ID>TooManyFunctions:RadioConfigViewModel.kt$RadioConfigViewModel : ViewModel</ID>
<ID>UnusedPrivateMember:RadioConfigViewModel.kt$RadioConfigViewModel$private fun setChannels(channelUrl: String)</ID>
<ID>UnusedPrivateProperty:SettingsViewModel.kt$SettingsViewModel$val capabilities = Capabilities(node.metadata?.firmware_version)</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -21,7 +21,6 @@ import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.meshtastic.core.strings.getString
import org.junit.Assert
import org.junit.Rule
@ -30,28 +29,22 @@ import org.junit.runner.RunWith
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.save
import org.meshtastic.proto.ClientOnlyProtos.DeviceProfile
import org.meshtastic.proto.deviceProfile
import org.meshtastic.proto.position
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.Position
@RunWith(AndroidJUnit4::class)
class EditDeviceProfileDialogTest {
@get:Rule val composeTestRule = createComposeRule()
private fun getString(id: Int): String = InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
private val title = "Export configuration"
private val deviceProfile = deviceProfile {
longName = "Long name"
shortName = "Short name"
channelUrl = "https://meshtastic.org/e/#CgMSAQESBggBQANIAQ"
fixedPosition = position {
latitudeI = 327766650
longitudeI = -967969890
altitude = 138
}
}
private val deviceProfile =
DeviceProfile(
long_name = "Long name",
short_name = "Short name",
channel_url = "https://meshtastic.org/e/#CgMSAQESBggBQANIAQ",
fixed_position = Position(latitude_i = 327766650, longitude_i = -967969890, altitude = 138),
)
private fun testEditDeviceProfileDialog(onDismiss: () -> Unit = {}, onConfirm: (DeviceProfile) -> Unit = {}) =
composeTestRule.setContent {

View file

@ -38,8 +38,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.rounded.AppSettingsAlt
import androidx.compose.material.icons.rounded.BugReport
import androidx.compose.material.icons.rounded.FilterList
import androidx.compose.material.icons.rounded.FormatPaint
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Language
@ -56,7 +54,6 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@ -119,7 +116,7 @@ import org.meshtastic.feature.settings.radio.component.EditDeviceProfileDialog
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import org.meshtastic.feature.settings.util.LanguageUtils
import org.meshtastic.feature.settings.util.LanguageUtils.languageMap
import org.meshtastic.proto.ClientOnlyProtos.DeviceProfile
import org.meshtastic.proto.DeviceProfile
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@ -139,7 +136,7 @@ fun SettingsScreen(
val ourNode by settingsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by settingsViewModel.isConnected.collectAsStateWithLifecycle(false)
val isOtaCapable by settingsViewModel.isOtaCapable.collectAsStateWithLifecycle()
val destNode by viewModel.destNode.collectAsState()
val destNode by viewModel.destNode.collectAsStateWithLifecycle()
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
var isWaiting by remember { mutableStateOf(false) }
if (isWaiting) {
@ -192,7 +189,7 @@ fun SettingsScreen(
viewModel.installProfile(it)
} else {
deviceProfile = it
val nodeName = it.shortName.ifBlank { "node" }
val nodeName = (it.short_name ?: "").ifBlank { "node" }
val dateFormat = java.text.SimpleDateFormat("yyyyMMdd", java.util.Locale.getDefault())
val dateStr = dateFormat.format(java.util.Date())
val fileName = "Meshtastic_${nodeName}_${dateStr}_nodeConfig.cfg"
@ -231,9 +228,9 @@ fun SettingsScreen(
title = stringResource(Res.string.bottom_nav_settings),
subtitle =
if (state.isLocal) {
ourNode?.user?.longName
ourNode?.user?.long_name
} else {
val remoteName = destNode?.user?.longName ?: ""
val remoteName = destNode?.user?.long_name ?: ""
stringResource(Res.string.remotely_administrating, remoteName)
},
ourNode = ourNode,
@ -248,7 +245,7 @@ fun SettingsScreen(
Column(modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp)) {
RadioConfigItemList(
state = state,
isManaged = localConfig.security.isManaged,
isManaged = localConfig.security?.is_managed ?: false,
node = destNode,
excludedModulesUnlocked = excludedModulesUnlocked,
isOtaCapable = isOtaCapable,
@ -277,180 +274,169 @@ fun SettingsScreen(
val context = LocalContext.current
if (state.isLocal) {
TitledCard(title = stringResource(Res.string.app_settings), modifier = Modifier.padding(top = 16.dp)) {
if (state.analyticsAvailable) {
val allowed by viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(false)
SwitchListItem(
text = stringResource(Res.string.analytics_okay),
checked = allowed,
leadingIcon = Icons.Rounded.BugReport,
onClick = { viewModel.toggleAnalyticsAllowed() },
)
}
val locationPermissionsState =
rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
val isGpsDisabled = context.gpsDisabled()
val provideLocation by settingsViewModel.provideLocation.collectAsStateWithLifecycle()
LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) {
if (provideLocation) {
if (locationPermissionsState.allPermissionsGranted) {
if (!isGpsDisabled) {
settingsViewModel.meshService?.startProvideLocation()
} else {
context.showToast(Res.string.location_disabled)
}
} else {
// Request permissions if not granted and user wants to provide location
locationPermissionsState.launchMultiplePermissionRequest()
}
} else {
settingsViewModel.meshService?.stopProvideLocation()
}
}
TitledCard(title = stringResource(Res.string.app_settings), modifier = Modifier.padding(top = 16.dp)) {
if (state.analyticsAvailable) {
val allowed by viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(false)
SwitchListItem(
text = stringResource(Res.string.provide_location_to_mesh),
leadingIcon = Icons.Rounded.LocationOn,
enabled = !isGpsDisabled,
checked = provideLocation,
onClick = { settingsViewModel.setProvideLocation(!provideLocation) },
text = stringResource(Res.string.analytics_okay),
checked = allowed,
leadingIcon = Icons.Default.BugReport,
onClick = { viewModel.toggleAnalyticsAllowed() },
)
}
val settingsLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
) {}
val locationPermissionsState =
rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
val isGpsDisabled = context.gpsDisabled()
val provideLocation by settingsViewModel.provideLocation.collectAsStateWithLifecycle()
// On Android 12 and below, system app settings for language are not available. Use the in-app
// language
// picker for these devices.
val useInAppLangPicker = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
ListItem(
text = stringResource(Res.string.preferences_language),
leadingIcon = Icons.Rounded.Language,
trailingIcon = if (useInAppLangPicker) null else Icons.AutoMirrored.Rounded.KeyboardArrowRight,
) {
if (useInAppLangPicker) {
showLanguagePickerDialog = true
} else {
val intent = Intent(ACTION_APP_LOCALE_SETTINGS, "package:${context.packageName}".toUri())
if (intent.resolveActivity(context.packageManager) != null) {
settingsLauncher.launch(intent)
LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) {
if (provideLocation) {
if (locationPermissionsState.allPermissionsGranted) {
if (!isGpsDisabled) {
settingsViewModel.meshService?.startProvideLocation()
} else {
// Fall back to the in-app picker
showLanguagePickerDialog = true
context.showToast(Res.string.location_disabled)
}
} else {
// Request permissions if not granted and user wants to provide location
locationPermissionsState.launchMultiplePermissionRequest()
}
} else {
settingsViewModel.meshService?.stopProvideLocation()
}
}
SwitchListItem(
text = stringResource(Res.string.provide_location_to_mesh),
leadingIcon = Icons.Rounded.LocationOn,
enabled = !isGpsDisabled,
checked = provideLocation,
onClick = { settingsViewModel.setProvideLocation(!provideLocation) },
)
val settingsLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {}
// On Android 12 and below, system app settings for language are not available. Use the in-app language
// picker for these devices.
val useInAppLangPicker = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
ListItem(
text = stringResource(Res.string.preferences_language),
leadingIcon = Icons.Rounded.Language,
trailingIcon = if (useInAppLangPicker) null else Icons.AutoMirrored.Rounded.KeyboardArrowRight,
) {
if (useInAppLangPicker) {
showLanguagePickerDialog = true
} else {
val intent = Intent(ACTION_APP_LOCALE_SETTINGS, "package:${context.packageName}".toUri())
if (intent.resolveActivity(context.packageManager) != null) {
settingsLauncher.launch(intent)
} else {
// Fall back to the in-app picker
showLanguagePickerDialog = true
}
}
}
ListItem(
text = stringResource(Res.string.theme),
leadingIcon = Icons.Rounded.FormatPaint,
trailingIcon = null,
) {
showThemePickerDialog = true
ListItem(
text = stringResource(Res.string.theme),
leadingIcon = Icons.Rounded.FormatPaint,
trailingIcon = null,
) {
showThemePickerDialog = true
}
// Node DB cache limit (App setting)
val cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value
val cacheItems = remember {
(DatabaseConstants.MIN_CACHE_LIMIT..DatabaseConstants.MAX_CACHE_LIMIT).map {
it.toLong() to it.toString()
}
}
DropDownPreference(
title = stringResource(Res.string.device_db_cache_limit),
enabled = true,
items = cacheItems,
selectedItem = cacheLimit.toLong(),
onItemSelected = { selected -> settingsViewModel.setDbCacheLimit(selected.toInt()) },
summary = stringResource(Res.string.device_db_cache_limit_summary),
)
ListItem(
text = "Message Filter",
leadingIcon = Icons.Rounded.FilterList,
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
) {
onNavigate(SettingsRoutes.FilterSettings)
} // Node DB cache limit (App setting)
val cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value
val cacheItems = remember {
(DatabaseConstants.MIN_CACHE_LIMIT..DatabaseConstants.MAX_CACHE_LIMIT).map {
it.toLong() to it.toString()
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val nodeName = ourNode?.user?.short_name ?: ""
val exportRangeTestLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) }
}
}
DropDownPreference(
title = stringResource(Res.string.device_db_cache_limit),
enabled = true,
items = cacheItems,
selectedItem = cacheLimit.toLong(),
onItemSelected = { selected -> settingsViewModel.setDbCacheLimit(selected.toInt()) },
summary = stringResource(Res.string.device_db_cache_limit_summary),
)
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val nodeName = ourNode?.user?.shortName ?: ""
val exportRangeTestLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) }
}
ListItem(
text = stringResource(Res.string.save_rangetest),
leadingIcon = Icons.Rounded.Output,
trailingIcon = null,
) {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/csv"
putExtra(Intent.EXTRA_TITLE, "Meshtastic_rangetest_${nodeName}_$timestamp.csv")
}
ListItem(
text = stringResource(Res.string.save_rangetest),
leadingIcon = Icons.Rounded.Output,
trailingIcon = null,
) {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/csv"
putExtra(Intent.EXTRA_TITLE, "Meshtastic_rangetest_${nodeName}_$timestamp.csv")
}
exportRangeTestLauncher.launch(intent)
}
exportRangeTestLauncher.launch(intent)
}
val exportDataLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) }
}
val exportDataLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) }
}
ListItem(
text = stringResource(Res.string.export_data_csv),
leadingIcon = Icons.Rounded.Output,
trailingIcon = null,
) {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/csv"
putExtra(Intent.EXTRA_TITLE, "Meshtastic_datalog_${nodeName}_$timestamp.csv")
}
exportDataLauncher.launch(intent)
}
ListItem(
text = stringResource(Res.string.export_data_csv),
leadingIcon = Icons.Rounded.Output,
trailingIcon = null,
) {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/csv"
putExtra(Intent.EXTRA_TITLE, "Meshtastic_datalog_${nodeName}_$timestamp.csv")
}
exportDataLauncher.launch(intent)
}
ListItem(
text = stringResource(Res.string.intro_show),
leadingIcon = Icons.Rounded.WavingHand,
trailingIcon = null,
) {
settingsViewModel.showAppIntro()
}
ListItem(
text = stringResource(Res.string.intro_show),
leadingIcon = Icons.Rounded.WavingHand,
trailingIcon = null,
) {
settingsViewModel.showAppIntro()
}
ListItem(
text = stringResource(Res.string.system_settings),
leadingIcon = Icons.Rounded.AppSettingsAlt,
trailingIcon = null,
) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.fromParts("package", context.packageName, null)
settingsLauncher.launch(intent)
}
ListItem(
text = stringResource(Res.string.system_settings),
leadingIcon = Icons.Rounded.AppSettingsAlt,
trailingIcon = null,
) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.fromParts("package", context.packageName, null)
settingsLauncher.launch(intent)
}
ListItem(
text = stringResource(Res.string.acknowledgements),
leadingIcon = Icons.Rounded.Info,
trailingIcon = null,
) {
onNavigate(SettingsRoutes.About)
}
ListItem(
text = stringResource(Res.string.acknowledgements),
leadingIcon = Icons.Rounded.Info,
trailingIcon = null,
) {
onNavigate(SettingsRoutes.About)
}
AppVersionButton(
excludedModulesUnlocked = excludedModulesUnlocked,
appVersionName = settingsViewModel.appVersionName,
) {
settingsViewModel.unlockExcludedModules()
}
AppVersionButton(
excludedModulesUnlocked = excludedModulesUnlocked,
appVersionName = settingsViewModel.appVersionName,
) {
settingsViewModel.unlockExcludedModules()
}
}
}

View file

@ -58,15 +58,15 @@ import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.LocalOnlyProtos.LocalConfig
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.Portnums
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.PortNum
import java.io.BufferedWriter
import java.io.FileNotFoundException
import java.io.FileWriter
import java.util.Locale
import javax.inject.Inject
import kotlin.math.roundToInt
import org.meshtastic.proto.Position as ProtoPosition
@Suppress("LongParameterList")
@HiltViewModel
@ -97,7 +97,7 @@ constructor(
serviceRepository.connectionState.map { it.isConnected() }.stateInWhileSubscribed(initialValue = false)
val localConfig: StateFlow<LocalConfig> =
radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig.getDefaultInstance())
radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())
val meshService: IMeshService?
get() = serviceRepository.meshService
@ -126,20 +126,17 @@ constructor(
if (node == null || !connectionState.isConnected()) {
flowOf(false)
} else if (radioPrefs.isBle() || radioPrefs.isSerial() || radioPrefs.isTcp()) {
val hwModel = node.user.hwModel.number
val hwModel = node.user.hw_model.value
val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel).getOrNull()
val capabilities = Capabilities(node.metadata?.firmwareVersion)
val isSerial = radioPrefs.isSerial()
// Support both Nordic DFU (requiresDfu) and ESP32 Unified OTA (supportsUnifiedOta)
val capabilities = Capabilities(node.metadata?.firmware_version)
// ESP32 Unified OTA is only supported via BLE or WiFi (TCP), not USB Serial.
val isEsp32OtaSupported = hw?.isEsp32Arc == true && capabilities.supportsEsp32Ota && !isSerial
// TODO: Re-enable when supportsUnifiedOta is added to DeviceHardware
val isEsp32OtaSupported = false
// hw?.supportsUnifiedOta == true && capabilities.supportsEsp32Ota && !radioPrefs.isSerial()
// Nordic DFU/USB update is supported for NRF52/RP2040.
// For ESP32, we do NOT support Serial updates from the app yet, even if requiresDfu is true
// (which might be set for S3 native USB, but is currently unused by our handlers).
val isDfuSupported = hw?.requiresDfu == true && hw.isEsp32Arc != true
flow { emit(isDfuSupported || isEsp32OtaSupported) }
flow { emit(hw?.requiresDfu == true || isEsp32OtaSupported) }
} else {
flowOf(false)
}
@ -214,14 +211,14 @@ constructor(
// Capture the current node value while we're still on main thread
val nodes = nodeRepository.nodeDBbyNum.value
// Converts a MeshProtos.Position (nullable) to a Position, but only if it's valid, otherwise returns null.
// Converts a ProtoPosition (nullable) to a Position, but only if it's valid, otherwise returns null.
// The returned Position is guaranteed to be non-null and valid, or null if the input was null or invalid.
val positionToPos: (MeshProtos.Position?) -> Position? = { meshPosition ->
val positionToPos: (ProtoPosition?) -> Position? = { meshPosition ->
meshPosition?.let { Position(it) }?.takeIf { it.isValid() }
}
writeToUri(uri) { writer ->
val nodePositions = mutableMapOf<Int, MeshProtos.Position?>()
val nodePositions = mutableMapOf<Int, ProtoPosition?>()
@Suppress("MaxLineLength")
writer.appendLine(
@ -249,12 +246,12 @@ constructor(
// packets must have rxSNR, and optionally match the filter given as a param.
if (
(filterPortnum == null || proto.decoded.portnumValue == filterPortnum) &&
proto.rxSnr != 0.0f
(filterPortnum == null || (proto.decoded?.portnum?.value ?: 0) == filterPortnum) &&
(proto.rx_snr ?: 0f) != 0.0f
) {
val rxDateTime = dateFormat.format(packet.received_date)
val rxFrom = proto.from.toUInt()
val senderName = nodes[proto.from]?.user?.longName ?: ""
val senderName = nodes[proto.from]?.user?.long_name ?: ""
// sender lat & long
val senderPosition = nodePositions[proto.from]
@ -268,7 +265,7 @@ constructor(
val rxLat = rxPos?.latitude ?: ""
val rxLong = rxPos?.longitude ?: ""
val rxAlt = rxPos?.altitude ?: ""
val rxSnr = proto.rxSnr
val rxSnr = proto.rx_snr
// Calculate the distance if both positions are valid
@ -286,19 +283,19 @@ constructor(
.toString()
}
val hopLimit = proto.hopLimit
val hopLimit = proto.hop_limit ?: 0
val decoded = proto.decoded
val encrypted = proto.encrypted
val payload =
when {
proto.decoded.portnumValue !in
setOf(
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
Portnums.PortNum.RANGE_TEST_APP_VALUE,
) -> "<${proto.decoded.portnum}>"
(decoded?.portnum?.value ?: 0) !in
setOf(PortNum.TEXT_MESSAGE_APP.value, PortNum.RANGE_TEST_APP.value) ->
"<${decoded?.portnum}>"
proto.hasDecoded() -> proto.decoded.payload.toStringUtf8().replace("\"", "\"\"")
decoded != null -> decoded.payload.utf8().replace("\"", "\"\"")
proto.hasEncrypted() -> "${proto.encrypted.size()} encrypted bytes"
encrypted != null -> "${encrypted.size} encrypted bytes"
else -> ""
}

View file

@ -20,7 +20,6 @@ import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import com.google.protobuf.InvalidProtocolBufferException
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -39,14 +38,23 @@ import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toReadableString
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.AdminProtos
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.PaxcountProtos
import org.meshtastic.proto.Portnums.PortNum
import org.meshtastic.proto.StoreAndForwardProtos
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Position
import org.meshtastic.proto.RouteDiscovery
import org.meshtastic.proto.Routing
import org.meshtastic.proto.StoreAndForward
import org.meshtastic.proto.StoreForwardPlusPlus
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import org.meshtastic.proto.Waypoint
import java.text.DateFormat
import java.util.Date
import java.util.Locale
@ -277,7 +285,7 @@ constructor(
combine(searchManager.searchText, filterManager.filteredLogs) { searchText, logs ->
searchManager.findSearchMatches(searchText, logs)
}
.collect { matches ->
.collect {
searchManager.updateMatches(searchManager.searchText.value, filterManager.filteredLogs.value)
}
}
@ -302,32 +310,30 @@ constructor(
/** Transform the input [MeshLog] by enhancing the raw message with annotations. */
private fun annotateMeshLogMessage(meshLog: MeshLog): String = when (meshLog.message_type) {
"LogRecord" -> meshLog.fromRadio.logRecord.toString().replace("\\n\"", "\"")
"LogRecord" -> meshLog.fromRadio.log_record.toString().replace("\\n\"", "\"")
"Packet" -> meshLog.meshPacket?.let { packet -> annotatePacketLog(packet) } ?: meshLog.raw_message
"NodeInfo" ->
meshLog.nodeInfo?.let { nodeInfo -> annotateRawMessage(meshLog.raw_message, nodeInfo.num) }
?: meshLog.raw_message
"MyNodeInfo" ->
meshLog.myNodeInfo?.let { nodeInfo -> annotateRawMessage(meshLog.raw_message, nodeInfo.myNodeNum) }
meshLog.myNodeInfo?.let { nodeInfo -> annotateRawMessage(meshLog.raw_message, nodeInfo.my_node_num) }
?: meshLog.raw_message
else -> meshLog.raw_message
}
private fun annotatePacketLog(packet: MeshProtos.MeshPacket): String {
val builder = packet.toBuilder()
val hasDecoded = builder.hasDecoded()
val decoded = if (hasDecoded) builder.decoded else null
if (hasDecoded) builder.clearDecoded()
val baseText = builder.build().toString().trimEnd()
private fun annotatePacketLog(packet: MeshPacket): String {
val decoded = packet.decoded
val basePacket = packet.copy(decoded = null)
val baseText = basePacket.toString().trimEnd()
var result =
if (hasDecoded && decoded != null) {
if (decoded != null) {
val decodedText = decoded.toString().trimEnd().prependIndent(" ")
"$baseText\ndecoded {\n$decodedText\n}"
} else {
baseText
}
val relayNode = packet.relayNode
val relayNode = packet.relay_node ?: 0
var relayNodeAnnotation: String? = null
val placeholder = "___RELAY_NODE___"
@ -337,7 +343,7 @@ constructor(
Packet.getRelayNode(relayNode, nodeList, myNodeNum)?.let { node ->
val relayId = node.user.id
val relayName = node.user.longName
val relayName = node.user.long_name
val regex = Regex("""\brelay_node: ${relayNode.toUInt()}\b""")
if (regex.containsMatchIn(result)) {
relayNodeAnnotation = "relay_node: $relayName ($relayId)"
@ -364,7 +370,7 @@ constructor(
var mutated = false
nodeIds.toSet().forEach { nodeId -> mutated = mutated or msg.annotateNodeId(nodeId) }
return if (mutated) {
return msg.toString()
msg.toString()
} else {
rawMessage
}
@ -375,12 +381,10 @@ constructor(
val nodeIdStr = nodeId.toUInt().toString()
// Only match if whitespace before and after
val regex = Regex("""(?<=\s|^)${Regex.escape(nodeIdStr)}(?=\s|$)""")
regex.find(this)?.let { matchResult ->
matchResult.groupValues.let { _ ->
regex.findAll(this).toList().asReversed().forEach { match ->
val idx = match.range.last + 1
insert(idx, " (${nodeId.asNodeId()})")
}
regex.find(this)?.let { _ ->
regex.findAll(this).toList().asReversed().forEach { match ->
val idx = match.range.last + 1
insert(idx, " (${nodeId.asNodeId()})")
}
return true
}
@ -434,70 +438,82 @@ constructor(
* @return A human-readable string representation of the decoded payload, or an error message if decoding fails, or
* null if the log does not contain a decodable packet.
*/
@Suppress("detekt:CyclomaticComplexMethod") // large switch that detekt doesn't parse well.
@Suppress("CyclomaticComplexMethod", "NestedBlockDepth")
private fun decodePayloadFromMeshLog(log: MeshLog): String? {
var result: String? = null
val packet = log.meshPacket
if (packet == null || !packet.hasDecoded()) {
result = null
} else {
val portnum = packet.decoded.portnumValue
val payload = packet.decoded.payload.toByteArray()
result =
try {
when (portnum) {
PortNum.TEXT_MESSAGE_APP_VALUE,
PortNum.ALERT_APP_VALUE,
-> payload.toString(Charsets.UTF_8)
PortNum.POSITION_APP_VALUE -> MeshProtos.Position.parseFrom(payload).toString()
PortNum.WAYPOINT_APP_VALUE -> MeshProtos.Waypoint.parseFrom(payload).toString()
PortNum.NODEINFO_APP_VALUE -> MeshProtos.User.parseFrom(payload).toString()
PortNum.TELEMETRY_APP_VALUE -> TelemetryProtos.Telemetry.parseFrom(payload).toString()
PortNum.ROUTING_APP_VALUE -> MeshProtos.Routing.parseFrom(payload).toString()
PortNum.ADMIN_APP_VALUE -> AdminProtos.AdminMessage.parseFrom(payload).toString()
PortNum.PAXCOUNTER_APP_VALUE -> PaxcountProtos.Paxcount.parseFrom(payload).toString()
PortNum.STORE_FORWARD_APP_VALUE ->
StoreAndForwardProtos.StoreAndForward.parseFrom(payload).toString()
PortNum.STORE_FORWARD_PLUSPLUS_APP_VALUE ->
MeshProtos.StoreForwardPlusPlus.parseFrom(payload).toString()
PortNum.NEIGHBORINFO_APP_VALUE -> decodeNeighborInfo(payload)
PortNum.TRACEROUTE_APP_VALUE -> decodeTraceroute(packet, payload)
else -> payload.joinToString(" ") { HEX_FORMAT.format(it) }
}
} catch (e: InvalidProtocolBufferException) {
"Failed to decode payload: ${e.message}"
}
val decoded = packet?.decoded ?: return null
val portnumValue = decoded.portnum.value
val payload = decoded.payload.toByteArray()
return try {
when (portnumValue) {
PortNum.TEXT_MESSAGE_APP.value,
PortNum.ALERT_APP.value,
-> payload.toString(Charsets.UTF_8)
PortNum.POSITION_APP.value ->
Position.ADAPTER.decodeOrNull(payload)?.let { Position.ADAPTER.toReadableString(it) }
?: "Failed to decode Position"
PortNum.WAYPOINT_APP.value ->
Waypoint.ADAPTER.decodeOrNull(payload)?.let { Waypoint.ADAPTER.toReadableString(it) }
?: "Failed to decode Waypoint"
PortNum.NODEINFO_APP.value ->
User.ADAPTER.decodeOrNull(payload)?.let { User.ADAPTER.toReadableString(it) }
?: "Failed to decode User"
PortNum.TELEMETRY_APP.value ->
Telemetry.ADAPTER.decodeOrNull(payload)?.let { Telemetry.ADAPTER.toReadableString(it) }
?: "Failed to decode Telemetry"
PortNum.ROUTING_APP.value ->
Routing.ADAPTER.decodeOrNull(payload)?.let { Routing.ADAPTER.toReadableString(it) }
?: "Failed to decode Routing"
PortNum.ADMIN_APP.value ->
AdminMessage.ADAPTER.decodeOrNull(payload)?.let { AdminMessage.ADAPTER.toReadableString(it) }
?: "Failed to decode AdminMessage"
PortNum.PAXCOUNTER_APP.value ->
Paxcount.ADAPTER.decodeOrNull(payload)?.let { Paxcount.ADAPTER.toReadableString(it) }
?: "Failed to decode Paxcount"
PortNum.STORE_FORWARD_APP.value ->
StoreAndForward.ADAPTER.decodeOrNull(payload)?.let { StoreAndForward.ADAPTER.toReadableString(it) }
?: "Failed to decode StoreAndForward"
PortNum.STORE_FORWARD_PLUSPLUS_APP.value ->
StoreForwardPlusPlus.ADAPTER.decodeOrNull(payload)?.let {
StoreForwardPlusPlus.ADAPTER.toReadableString(it)
} ?: "Failed to decode StoreForwardPlusPlus"
PortNum.NEIGHBORINFO_APP.value -> decodeNeighborInfo(payload)
PortNum.TRACEROUTE_APP.value -> decodeTraceroute(packet, payload)
else -> payload.joinToString(" ") { HEX_FORMAT.format(it) }
}
} catch (e: Exception) {
"Failed to decode payload: ${e.message}"
}
return result
}
private fun formatNodeWithShortName(nodeNum: Int): String {
val user = nodeRepository.nodeDBbyNum.value[nodeNum]?.user
val shortName = user?.shortName?.takeIf { it.isNotEmpty() } ?: ""
val shortName = user?.short_name?.takeIf { it.isNotEmpty() } ?: ""
val nodeId = "!%08x".format(nodeNum)
return if (shortName.isNotEmpty()) "$nodeId ($shortName)" else nodeId
}
private fun decodeNeighborInfo(payload: ByteArray): String {
val info = MeshProtos.NeighborInfo.parseFrom(payload)
val info = NeighborInfo.ADAPTER.decode(payload)
return buildString {
appendLine("NeighborInfo:")
appendLine(" node_id: ${formatNodeWithShortName(info.nodeId)}")
appendLine(" last_sent_by_id: ${formatNodeWithShortName(info.lastSentById)}")
appendLine(" node_broadcast_interval_secs: ${info.nodeBroadcastIntervalSecs}")
if (info.neighborsCount > 0) {
appendLine(" node_id: ${formatNodeWithShortName(info.node_id ?: 0)}")
appendLine(" last_sent_by_id: ${formatNodeWithShortName(info.last_sent_by_id ?: 0)}")
appendLine(" node_broadcast_interval_secs: ${info.node_broadcast_interval_secs}")
if (info.neighbors.isNotEmpty()) {
appendLine(" neighbors:")
info.neighborsList.forEach { n ->
appendLine(" - node_id: ${formatNodeWithShortName(n.nodeId)} snr: ${n.snr}")
info.neighbors.forEach { n ->
appendLine(" - node_id: ${formatNodeWithShortName(n.node_id ?: 0)} snr: ${n.snr}")
}
}
}
}
private fun decodeTraceroute(packet: MeshProtos.MeshPacket, payload: ByteArray): String {
private fun decodeTraceroute(packet: MeshPacket, payload: ByteArray): String {
val getUsername: (Int) -> String = { nodeNum -> formatNodeWithShortName(nodeNum) }
return packet.getTracerouteResponse(getUsername)
?: runCatching { MeshProtos.RouteDiscovery.parseFrom(payload).toString() }.getOrNull()
?: runCatching { RouteDiscovery.ADAPTER.decode(payload).toString() }.getOrNull()
?: payload.joinToString(" ") { HEX_FORMAT.format(it) }
}
}

View file

@ -18,15 +18,15 @@ package org.meshtastic.feature.settings.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.rounded.Bluetooth
import androidx.compose.material.icons.rounded.CellTower
import androidx.compose.material.icons.rounded.DisplaySettings
import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Power
import androidx.compose.material.icons.rounded.Router
import androidx.compose.material.icons.rounded.Security
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.CellTower
import androidx.compose.material.icons.filled.DisplaySettings
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Power
import androidx.compose.material.icons.filled.Router
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.ui.graphics.vector.ImageVector
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.navigation.Route
@ -42,59 +42,44 @@ import org.meshtastic.core.strings.position
import org.meshtastic.core.strings.power
import org.meshtastic.core.strings.security
import org.meshtastic.core.strings.user
import org.meshtastic.proto.AdminProtos
import org.meshtastic.proto.MeshProtos.DeviceMetadata
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.DeviceMetadata
enum class ConfigRoute(val title: StringResource, val route: Route, val icon: ImageVector?, val type: Int = 0) {
USER(Res.string.user, SettingsRoutes.User, Icons.Rounded.Person, 0),
USER(Res.string.user, SettingsRoutes.User, Icons.Default.Person, 0),
CHANNELS(Res.string.channels, SettingsRoutes.ChannelConfig, Icons.AutoMirrored.Default.List, 0),
DEVICE(
Res.string.device,
SettingsRoutes.Device,
Icons.Rounded.Router,
AdminProtos.AdminMessage.ConfigType.DEVICE_CONFIG_VALUE,
),
DEVICE(Res.string.device, SettingsRoutes.Device, Icons.Default.Router, AdminMessage.ConfigType.DEVICE_CONFIG.value),
POSITION(
Res.string.position,
SettingsRoutes.Position,
Icons.Rounded.LocationOn,
AdminProtos.AdminMessage.ConfigType.POSITION_CONFIG_VALUE,
),
POWER(
Res.string.power,
SettingsRoutes.Power,
Icons.Rounded.Power,
AdminProtos.AdminMessage.ConfigType.POWER_CONFIG_VALUE,
Icons.Default.LocationOn,
AdminMessage.ConfigType.POSITION_CONFIG.value,
),
POWER(Res.string.power, SettingsRoutes.Power, Icons.Default.Power, AdminMessage.ConfigType.POWER_CONFIG.value),
NETWORK(
Res.string.network,
SettingsRoutes.Network,
Icons.Rounded.Wifi,
AdminProtos.AdminMessage.ConfigType.NETWORK_CONFIG_VALUE,
Icons.Default.Wifi,
AdminMessage.ConfigType.NETWORK_CONFIG.value,
),
DISPLAY(
Res.string.display,
SettingsRoutes.Display,
Icons.Rounded.DisplaySettings,
AdminProtos.AdminMessage.ConfigType.DISPLAY_CONFIG_VALUE,
),
LORA(
Res.string.lora,
SettingsRoutes.LoRa,
Icons.Rounded.CellTower,
AdminProtos.AdminMessage.ConfigType.LORA_CONFIG_VALUE,
Icons.Default.DisplaySettings,
AdminMessage.ConfigType.DISPLAY_CONFIG.value,
),
LORA(Res.string.lora, SettingsRoutes.LoRa, Icons.Default.CellTower, AdminMessage.ConfigType.LORA_CONFIG.value),
BLUETOOTH(
Res.string.bluetooth,
SettingsRoutes.Bluetooth,
Icons.Rounded.Bluetooth,
AdminProtos.AdminMessage.ConfigType.BLUETOOTH_CONFIG_VALUE,
Icons.Default.Bluetooth,
AdminMessage.ConfigType.BLUETOOTH_CONFIG.value,
),
SECURITY(
Res.string.security,
SettingsRoutes.Security,
Icons.Rounded.Security,
AdminProtos.AdminMessage.ConfigType.SECURITY_CONFIG_VALUE,
Icons.Default.Security,
AdminMessage.ConfigType.SECURITY_CONFIG.value,
),
;
@ -102,8 +87,8 @@ enum class ConfigRoute(val title: StringResource, val route: Route, val icon: Im
private fun filterExcludedFrom(metadata: DeviceMetadata?): List<ConfigRoute> = entries.filter {
when {
metadata == null -> true // Include all routes if metadata is null
it == BLUETOOTH -> metadata.hasBluetooth
it == NETWORK -> metadata.hasWifi || metadata.hasEthernet
it == BLUETOOTH -> metadata.hasBluetooth == true
it == NETWORK -> metadata.hasWifi == true || metadata.hasEthernet == true
else -> true // Include all other routes by default
}
}

View file

@ -50,8 +50,8 @@ import org.meshtastic.core.strings.serial
import org.meshtastic.core.strings.status_message
import org.meshtastic.core.strings.store_forward
import org.meshtastic.core.strings.telemetry
import org.meshtastic.proto.AdminProtos
import org.meshtastic.proto.MeshProtos.DeviceMetadata
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.DeviceMetadata
enum class ModuleRoute(
val title: StringResource,
@ -60,89 +60,84 @@ enum class ModuleRoute(
val type: Int = 0,
val isSupported: (Capabilities) -> Boolean = { true },
) {
MQTT(
Res.string.mqtt,
SettingsRoutes.MQTT,
Icons.Rounded.Cloud,
AdminProtos.AdminMessage.ModuleConfigType.MQTT_CONFIG_VALUE,
),
MQTT(Res.string.mqtt, SettingsRoutes.MQTT, Icons.Rounded.Cloud, AdminMessage.ModuleConfigType.MQTT_CONFIG.value),
SERIAL(
Res.string.serial,
SettingsRoutes.Serial,
Icons.Rounded.Usb,
AdminProtos.AdminMessage.ModuleConfigType.SERIAL_CONFIG_VALUE,
AdminMessage.ModuleConfigType.SERIAL_CONFIG.value,
),
EXT_NOTIFICATION(
Res.string.external_notification,
SettingsRoutes.ExtNotification,
Icons.Rounded.Notifications,
AdminProtos.AdminMessage.ModuleConfigType.EXTNOTIF_CONFIG_VALUE,
AdminMessage.ModuleConfigType.EXTNOTIF_CONFIG.value,
),
STORE_FORWARD(
Res.string.store_forward,
SettingsRoutes.StoreForward,
Icons.AutoMirrored.Default.Forward,
AdminProtos.AdminMessage.ModuleConfigType.STOREFORWARD_CONFIG_VALUE,
AdminMessage.ModuleConfigType.STOREFORWARD_CONFIG.value,
),
RANGE_TEST(
Res.string.range_test,
SettingsRoutes.RangeTest,
Icons.Rounded.Speed,
AdminProtos.AdminMessage.ModuleConfigType.RANGETEST_CONFIG_VALUE,
AdminMessage.ModuleConfigType.RANGETEST_CONFIG.value,
),
TELEMETRY(
Res.string.telemetry,
SettingsRoutes.Telemetry,
Icons.Rounded.DataUsage,
AdminProtos.AdminMessage.ModuleConfigType.TELEMETRY_CONFIG_VALUE,
AdminMessage.ModuleConfigType.TELEMETRY_CONFIG.value,
),
CANNED_MESSAGE(
Res.string.canned_message,
SettingsRoutes.CannedMessage,
Icons.AutoMirrored.Default.Message,
AdminProtos.AdminMessage.ModuleConfigType.CANNEDMSG_CONFIG_VALUE,
AdminMessage.ModuleConfigType.CANNEDMSG_CONFIG.value,
),
AUDIO(
Res.string.audio,
SettingsRoutes.Audio,
Icons.AutoMirrored.Default.VolumeUp,
AdminProtos.AdminMessage.ModuleConfigType.AUDIO_CONFIG_VALUE,
AdminMessage.ModuleConfigType.AUDIO_CONFIG.value,
),
REMOTE_HARDWARE(
Res.string.remote_hardware,
SettingsRoutes.RemoteHardware,
Icons.Rounded.SettingsRemote,
AdminProtos.AdminMessage.ModuleConfigType.REMOTEHARDWARE_CONFIG_VALUE,
AdminMessage.ModuleConfigType.REMOTEHARDWARE_CONFIG.value,
),
NEIGHBOR_INFO(
Res.string.neighbor_info,
SettingsRoutes.NeighborInfo,
Icons.Rounded.People,
AdminProtos.AdminMessage.ModuleConfigType.NEIGHBORINFO_CONFIG_VALUE,
AdminMessage.ModuleConfigType.NEIGHBORINFO_CONFIG.value,
),
AMBIENT_LIGHTING(
Res.string.ambient_lighting,
SettingsRoutes.AmbientLighting,
Icons.Rounded.LightMode,
AdminProtos.AdminMessage.ModuleConfigType.AMBIENTLIGHTING_CONFIG_VALUE,
AdminMessage.ModuleConfigType.AMBIENTLIGHTING_CONFIG.value,
),
DETECTION_SENSOR(
Res.string.detection_sensor,
SettingsRoutes.DetectionSensor,
Icons.Rounded.Sensors,
AdminProtos.AdminMessage.ModuleConfigType.DETECTIONSENSOR_CONFIG_VALUE,
AdminMessage.ModuleConfigType.DETECTIONSENSOR_CONFIG.value,
),
PAXCOUNTER(
Res.string.paxcounter,
SettingsRoutes.Paxcounter,
Icons.Rounded.PermScanWifi,
AdminProtos.AdminMessage.ModuleConfigType.PAXCOUNTER_CONFIG_VALUE,
AdminMessage.ModuleConfigType.PAXCOUNTER_CONFIG.value,
),
STATUS_MESSAGE(
Res.string.status_message,
SettingsRoutes.StatusMessage,
Icons.AutoMirrored.Default.Message,
AdminProtos.AdminMessage.ModuleConfigType.STATUSMESSAGE_CONFIG_VALUE,
AdminMessage.ModuleConfigType.STATUSMESSAGE_CONFIG.value,
isSupported = { it.supportsStatusMessage },
),
;
@ -152,9 +147,10 @@ enum class ModuleRoute(
companion object {
fun filterExcludedFrom(metadata: DeviceMetadata?): List<ModuleRoute> {
val capabilities = Capabilities(metadata?.firmwareVersion)
val capabilities = Capabilities(metadata?.firmware_version)
return entries.filter {
val isExcluded = metadata != null && (metadata.excludedModules and it.bitfield != 0)
val excludedModules = metadata?.excluded_modules ?: 0
val isExcluded = (excludedModules and it.bitfield) != 0
!isExcluded && it.isSupported(capabilities)
}
}

View file

@ -31,8 +31,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import co.touchlab.kermit.Logger
import com.google.protobuf.MessageLite
import com.meshtastic.core.strings.getString
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@ -65,24 +63,24 @@ import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.cant_shutdown
import org.meshtastic.core.strings.fetching_channel_indexed
import org.meshtastic.core.strings.fetching_config
import org.meshtastic.core.ui.util.getChannelList
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.util.UiText
import org.meshtastic.proto.AdminProtos
import org.meshtastic.proto.ChannelProtos
import org.meshtastic.proto.ClientOnlyProtos.DeviceProfile
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.ConfigProtos.Config.SecurityConfig
import org.meshtastic.proto.ConnStatusProtos
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.ModuleConfigProtos
import org.meshtastic.proto.Portnums
import org.meshtastic.proto.config
import org.meshtastic.proto.deviceProfile
import org.meshtastic.proto.moduleConfig
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceConnectionStatus
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Routing
import org.meshtastic.proto.User
import java.io.FileOutputStream
import javax.inject.Inject
@ -91,14 +89,14 @@ data class RadioConfigState(
val isLocal: Boolean = false,
val connected: Boolean = false,
val route: String = "",
val metadata: MeshProtos.DeviceMetadata? = null,
val userConfig: MeshProtos.User = MeshProtos.User.getDefaultInstance(),
val channelList: List<ChannelProtos.ChannelSettings> = emptyList(),
val radioConfig: ConfigProtos.Config = config {},
val moduleConfig: ModuleConfigProtos.ModuleConfig = moduleConfig {},
val metadata: DeviceMetadata? = null,
val userConfig: User = User(),
val channelList: List<ChannelSettings> = emptyList(),
val radioConfig: Config = Config(),
val moduleConfig: LocalModuleConfig = LocalModuleConfig(),
val ringtone: String = "",
val cannedMessageMessages: String = "",
val deviceConnectionStatus: ConnStatusProtos.DeviceConnectionStatus? = null,
val deviceConnectionStatus: DeviceConnectionStatus? = null,
val responseState: ResponseState<Boolean> = ResponseState.Empty,
val analyticsAvailable: Boolean = true,
val analyticsEnabled: Boolean = false,
@ -129,12 +127,15 @@ constructor(
analyticsPrefs.analyticsAllowed = !analyticsPrefs.analyticsAllowed
}
private val destNum = savedStateHandle.toRoute<SettingsRoutes.Settings>().destNum
private val destNum =
savedStateHandle.get<Int>("destNum")
?: runCatching { savedStateHandle.toRoute<SettingsRoutes.Settings>().destNum }.getOrNull()
private val _destNode = MutableStateFlow<Node?>(null)
val destNode: StateFlow<Node?>
get() = _destNode
private val requestIds = MutableStateFlow(emptySet<Int>())
private val requestIds = MutableStateFlow(hashSetOf<Int>())
private val _radioConfigState = MutableStateFlow(RadioConfigState())
val radioConfigState: StateFlow<RadioConfigState> = _radioConfigState
@ -142,7 +143,7 @@ constructor(
viewModelScope.launch { _radioConfigState.update { it.copy(nodeDbResetPreserveFavorites = preserveFavorites) } }
}
private val _currentDeviceProfile = MutableStateFlow(deviceProfile {})
private val _currentDeviceProfile = MutableStateFlow(DeviceProfile())
val currentDeviceProfile
get() = _currentDeviceProfile.value
@ -195,14 +196,13 @@ constructor(
val hasPaFan: Boolean
get() =
destNode.value?.user?.hwModel in
destNode.value?.user?.hw_model in
setOf(
null,
MeshProtos.HardwareModel.UNRECOGNIZED,
MeshProtos.HardwareModel.UNSET,
MeshProtos.HardwareModel.BETAFPV_2400_TX,
MeshProtos.HardwareModel.RADIOMASTER_900_BANDIT_NANO,
MeshProtos.HardwareModel.RADIOMASTER_900_BANDIT,
HardwareModel.UNSET,
HardwareModel.BETAFPV_2400_TX,
HardwareModel.RADIOMASTER_900_BANDIT_NANO,
HardwareModel.RADIOMASTER_900_BANDIT,
)
override fun onCleared() {
@ -216,12 +216,11 @@ constructor(
val packetId = service.packetId
try {
requestAction(service, packetId, destNum)
requestIds.update { it + packetId }
requestIds.update { it.apply { add(packetId) } }
_radioConfigState.update { state ->
val currentState = state.responseState
if (currentState is ResponseState.Loading) {
val total = maxOf(currentState.total, requestIds.value.size)
state.copy(responseState = currentState.copy(total = total))
if (state.responseState is ResponseState.Loading) {
val total = maxOf(requestIds.value.size, state.responseState.total)
state.copy(responseState = state.responseState.copy(total = total))
} else {
state.copy(
route = "", // setter (response is PortNum.ROUTING_APP)
@ -235,25 +234,15 @@ constructor(
}
}
fun setOwner(user: MeshProtos.User) {
val targetNode = destNode.value ?: return
// Ensure we are setting the owner for the intended target node
// This prevents accidentally updating the local node if the user object has the wrong ID
val fixedUser =
if (targetNode.user.id.isNotEmpty() && targetNode.user.id != user.id) {
Logger.w { "Fixing user ID mismatch in setOwner: form=${user.id} target=${targetNode.user.id}" }
user.toBuilder().setId(targetNode.user.id).build()
} else {
user
}
setRemoteOwner(targetNode.num, fixedUser)
fun setOwner(user: User) {
setRemoteOwner(destNode.value?.num ?: return, user)
}
private fun setRemoteOwner(destNum: Int, user: MeshProtos.User) = request(
private fun setRemoteOwner(destNum: Int, user: User) = request(
destNum,
{ service, packetId, _ ->
_radioConfigState.update { it.copy(userConfig = user) }
service.setRemoteOwner(packetId, destNum, user.toByteArray())
service.setRemoteOwner(packetId, destNum, user.encode())
},
"Request setOwner error",
)
@ -264,7 +253,7 @@ constructor(
"Request getOwner error",
)
fun updateChannels(new: List<ChannelProtos.ChannelSettings>, old: List<ChannelProtos.ChannelSettings>) {
fun updateChannels(new: List<ChannelSettings>, old: List<ChannelSettings>) {
val destNum = destNode.value?.num ?: return
getChannelList(new, old).forEach { setRemoteChannel(destNum, it) }
@ -280,12 +269,12 @@ constructor(
private fun setChannels(channelUrl: String) = viewModelScope.launch {
val new = channelUrl.toUri().toChannelSet()
val old = radioConfigRepository.channelSetFlow.firstOrNull() ?: return@launch
updateChannels(new.settingsList, old.settingsList)
updateChannels(new.settings, old.settings)
}
private fun setRemoteChannel(destNum: Int, channel: ChannelProtos.Channel) = request(
private fun setRemoteChannel(destNum: Int, channel: Channel) = request(
destNum,
{ service, packetId, dest -> service.setRemoteChannel(packetId, dest, channel.toByteArray()) },
{ service, packetId, dest -> service.setRemoteChannel(packetId, dest, channel.encode()) },
"Request setRemoteChannel error",
)
@ -295,15 +284,29 @@ constructor(
"Request getChannel error",
)
fun setConfig(config: ConfigProtos.Config) {
fun setConfig(config: Config) {
setRemoteConfig(destNode.value?.num ?: return, config)
}
private fun setRemoteConfig(destNum: Int, config: ConfigProtos.Config) = request(
private fun setRemoteConfig(destNum: Int, config: Config) = request(
destNum,
{ service, packetId, dest ->
_radioConfigState.update { it.copy(radioConfig = config) }
service.setRemoteConfig(packetId, dest, config.toByteArray())
_radioConfigState.update { state ->
state.copy(
radioConfig =
state.radioConfig.copy(
device = config.device ?: state.radioConfig.device,
position = config.position ?: state.radioConfig.position,
power = config.power ?: state.radioConfig.power,
network = config.network ?: state.radioConfig.network,
display = config.display ?: state.radioConfig.display,
lora = config.lora ?: state.radioConfig.lora,
bluetooth = config.bluetooth ?: state.radioConfig.bluetooth,
security = config.security ?: state.radioConfig.security,
),
)
}
service.setRemoteConfig(packetId, dest, config.encode())
},
"Request setConfig error",
)
@ -314,17 +317,38 @@ constructor(
"Request getConfig error",
)
fun setModuleConfig(config: ModuleConfigProtos.ModuleConfig) {
setModuleConfig(destNode.value?.num ?: return, config)
fun setModuleConfig(config: ModuleConfig) {
setRemoteModuleConfig(destNode.value?.num ?: return, config)
}
private fun setModuleConfig(destNum: Int, config: ModuleConfigProtos.ModuleConfig) = request(
private fun setRemoteModuleConfig(destNum: Int, config: ModuleConfig) = request(
destNum,
{ service, packetId, dest ->
_radioConfigState.update { it.copy(moduleConfig = config) }
service.setModuleConfig(packetId, dest, config.toByteArray())
_radioConfigState.update { state ->
state.copy(
moduleConfig =
state.moduleConfig.copy(
mqtt = config.mqtt ?: state.moduleConfig.mqtt,
serial = config.serial ?: state.moduleConfig.serial,
external_notification =
config.external_notification ?: state.moduleConfig.external_notification,
store_forward = config.store_forward ?: state.moduleConfig.store_forward,
range_test = config.range_test ?: state.moduleConfig.range_test,
telemetry = config.telemetry ?: state.moduleConfig.telemetry,
canned_message = config.canned_message ?: state.moduleConfig.canned_message,
audio = config.audio ?: state.moduleConfig.audio,
remote_hardware = config.remote_hardware ?: state.moduleConfig.remote_hardware,
neighbor_info = config.neighbor_info ?: state.moduleConfig.neighbor_info,
ambient_lighting = config.ambient_lighting ?: state.moduleConfig.ambient_lighting,
detection_sensor = config.detection_sensor ?: state.moduleConfig.detection_sensor,
paxcounter = config.paxcounter ?: state.moduleConfig.paxcounter,
statusmessage = config.statusmessage ?: state.moduleConfig.statusmessage,
),
)
}
service.setModuleConfig(packetId, dest, config.encode())
},
"Request setConfig error",
"Request setModuleConfig error",
)
private fun getModuleConfig(destNum: Int, configType: Int) = request(
@ -418,7 +442,7 @@ constructor(
AdminRoute.REBOOT.name -> requestReboot(destNum)
AdminRoute.SHUTDOWN.name ->
with(radioConfigState.value) {
if (metadata != null && !metadata.canShutdown) {
if (metadata != null && metadata.canShutdown != true) {
sendError(Res.string.cant_shutdown)
} else {
requestShutdown(destNum)
@ -444,8 +468,8 @@ constructor(
fun importProfile(uri: Uri, onResult: (DeviceProfile) -> Unit) = viewModelScope.launch(Dispatchers.IO) {
try {
app.contentResolver.openInputStream(uri).use { inputStream ->
val bytes = inputStream?.readBytes()
val protobuf = DeviceProfile.parseFrom(bytes)
val bytes = inputStream?.readBytes() ?: ByteArray(0)
val protobuf = DeviceProfile.ADAPTER.decode(bytes)
onResult(protobuf)
}
} catch (ex: Exception) {
@ -456,11 +480,11 @@ constructor(
fun exportProfile(uri: Uri, profile: DeviceProfile) = viewModelScope.launch { writeToUri(uri, profile) }
private suspend fun writeToUri(uri: Uri, message: MessageLite) = withContext(Dispatchers.IO) {
private suspend fun writeToUri(uri: Uri, message: com.squareup.wire.Message<*, *>) = withContext(Dispatchers.IO) {
try {
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream ->
message.writeTo(outputStream)
outputStream.write(message.encode())
}
}
setResponseStateSuccess()
@ -470,16 +494,16 @@ constructor(
}
}
fun exportSecurityConfig(uri: Uri, securityConfig: SecurityConfig) =
fun exportSecurityConfig(uri: Uri, securityConfig: Config.SecurityConfig) =
viewModelScope.launch { writeSecurityKeysJsonToUri(uri, securityConfig) }
private val indentSpaces = 4
private suspend fun writeSecurityKeysJsonToUri(uri: Uri, securityConfig: SecurityConfig) =
private suspend fun writeSecurityKeysJsonToUri(uri: Uri, securityConfig: Config.SecurityConfig) =
withContext(Dispatchers.IO) {
try {
val publicKeyBytes = securityConfig.publicKey.toByteArray()
val privateKeyBytes = securityConfig.privateKey.toByteArray()
val publicKeyBytes = securityConfig.public_key?.toByteArray() ?: ByteArray(0)
val privateKeyBytes = securityConfig.private_key?.toByteArray() ?: ByteArray(0)
// Convert byte arrays to Base64 strings for human readability in JSON
val publicKeyBase64 = Base64.encodeToString(publicKeyBytes, Base64.NO_WRAP)
@ -509,74 +533,57 @@ constructor(
}
}
@Suppress("CyclomaticComplexMethod")
fun installProfile(protobuf: DeviceProfile) {
val destNum = destNode.value?.num ?: return
with(protobuf) {
meshService?.beginEditSettings(destNum)
if (hasLongName() || hasShortName()) {
if (long_name != null || short_name != null) {
destNode.value?.user?.let {
val user =
MeshProtos.User.newBuilder()
.setId(it.id)
.setLongName(if (hasLongName()) longName else it.longName)
.setShortName(if (hasShortName()) shortName else it.shortName)
.setIsLicensed(it.isLicensed)
.build()
val user = it.copy(long_name = long_name ?: it.long_name, short_name = short_name ?: it.short_name)
setOwner(user)
}
}
if (hasChannelUrl()) {
try {
setChannels(channelUrl)
} catch (ex: Exception) {
Logger.e(ex) { "DeviceProfile channel import error" }
sendError(ex.customMessage)
}
config?.let { lc ->
lc.device?.let { setConfig(Config(device = it)) }
lc.position?.let { setConfig(Config(position = it)) }
lc.power?.let { setConfig(Config(power = it)) }
lc.network?.let { setConfig(Config(network = it)) }
lc.display?.let { setConfig(Config(display = it)) }
lc.lora?.let { setConfig(Config(lora = it)) }
lc.bluetooth?.let { setConfig(Config(bluetooth = it)) }
lc.security?.let { setConfig(Config(security = it)) }
}
if (hasConfig()) {
val descriptor = ConfigProtos.Config.getDescriptor()
config.allFields.forEach { (field, value) ->
val newConfig =
ConfigProtos.Config.newBuilder().setField(descriptor.findFieldByName(field.name), value).build()
setConfig(newConfig)
}
if (fixed_position != null) {
setFixedPosition(Position(fixed_position!!))
}
if (hasFixedPosition()) {
setFixedPosition(Position(fixedPosition))
}
if (hasModuleConfig()) {
val descriptor = ModuleConfigProtos.ModuleConfig.getDescriptor()
moduleConfig.allFields.forEach { (field, value) ->
val newConfig =
ModuleConfigProtos.ModuleConfig.newBuilder()
.setField(descriptor.findFieldByName(field.name), value)
.build()
setModuleConfig(newConfig)
}
module_config?.let { lmc ->
lmc.mqtt?.let { setModuleConfig(ModuleConfig(mqtt = it)) }
lmc.serial?.let { setModuleConfig(ModuleConfig(serial = it)) }
lmc.external_notification?.let { setModuleConfig(ModuleConfig(external_notification = it)) }
lmc.store_forward?.let { setModuleConfig(ModuleConfig(store_forward = it)) }
lmc.range_test?.let { setModuleConfig(ModuleConfig(range_test = it)) }
lmc.telemetry?.let { setModuleConfig(ModuleConfig(telemetry = it)) }
lmc.canned_message?.let { setModuleConfig(ModuleConfig(canned_message = it)) }
lmc.audio?.let { setModuleConfig(ModuleConfig(audio = it)) }
lmc.remote_hardware?.let { setModuleConfig(ModuleConfig(remote_hardware = it)) }
lmc.neighbor_info?.let { setModuleConfig(ModuleConfig(neighbor_info = it)) }
lmc.ambient_lighting?.let { setModuleConfig(ModuleConfig(ambient_lighting = it)) }
lmc.detection_sensor?.let { setModuleConfig(ModuleConfig(detection_sensor = it)) }
lmc.paxcounter?.let { setModuleConfig(ModuleConfig(paxcounter = it)) }
lmc.statusmessage?.let { setModuleConfig(ModuleConfig(statusmessage = it)) }
}
meshService?.commitEditSettings(destNum)
}
}
fun clearPacketResponse() {
requestIds.value = emptySet()
requestIds.value = hashSetOf()
_radioConfigState.update { it.copy(responseState = ResponseState.Empty) }
}
private fun getTitleForRoute(route: Enum<*>) = when (route) {
is ConfigRoute -> route.title
is ModuleRoute -> route.title
is AdminRoute -> route.title
else -> null
}
@Suppress("CyclomaticComplexMethod")
fun setResponseStateLoading(route: Enum<*>) {
val destNum = destNode.value?.num ?: return
val title = getTitleForRoute(route)
_radioConfigState.update {
RadioConfigState(
isLocal = it.isLocal,
@ -584,10 +591,7 @@ constructor(
route = route.name,
metadata = it.metadata,
nodeDbResetPreserveFavorites = it.nodeDbResetPreserveFavorites,
responseState =
ResponseState.Loading(
status = title?.let { t -> getString(Res.string.fetching_config, getString(t)) },
),
responseState = ResponseState.Loading(),
)
}
@ -602,7 +606,7 @@ constructor(
}
is AdminRoute -> {
getConfig(destNum, AdminProtos.AdminMessage.ConfigType.SESSIONKEY_CONFIG_VALUE)
getConfig(destNum, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value)
setResponseStateTotal(2)
}
@ -634,11 +638,10 @@ constructor(
mapConsentPrefs.setShouldReportLocation(nodeNum, shouldReportLocation)
}
private fun setResponseStateTotal(newTotal: Int) {
private fun setResponseStateTotal(total: Int) {
_radioConfigState.update { state ->
val currentState = state.responseState
if (currentState is ResponseState.Loading) {
state.copy(responseState = currentState.copy(total = newTotal))
if (state.responseState is ResponseState.Loading) {
state.copy(responseState = state.responseState.copy(total = total))
} else {
state // Return the unchanged state for other response states
}
@ -666,35 +669,32 @@ constructor(
_radioConfigState.update { it.copy(responseState = ResponseState.Error(error)) }
}
private fun incrementCompleted(status: String? = null) {
private fun incrementCompleted() {
_radioConfigState.update { state ->
if (state.responseState is ResponseState.Loading) {
val increment = state.responseState.completed + 1
state.copy(
responseState =
state.responseState.copy(completed = increment, status = status ?: state.responseState.status),
)
state.copy(responseState = state.responseState.copy(completed = increment))
} else {
state // Return the unchanged state for other response states
}
}
}
private fun processPacketResponse(packet: MeshProtos.MeshPacket) {
val data = packet.decoded
if (data.requestId !in requestIds.value) return
private fun processPacketResponse(packet: MeshPacket) {
val data = packet.decoded ?: return
if (data.request_id !in requestIds.value) return
val route = radioConfigState.value.route
val destNum = destNode.value?.num ?: return
val debugMsg = "requestId: ${data.requestId.toUInt()} to: ${destNum.toUInt()} received %s"
val debugMsg = "requestId: ${data.request_id.toUInt()} to: ${destNum.toUInt()} received %s"
if (data?.portnumValue == Portnums.PortNum.ROUTING_APP_VALUE) {
val parsed = MeshProtos.Routing.parseFrom(data.payload)
Logger.d { debugMsg.format(parsed.errorReason.name) }
if (parsed.errorReason != MeshProtos.Routing.Error.NONE) {
sendError(getStringResFrom(parsed.errorReasonValue))
if (data.portnum == PortNum.ROUTING_APP) {
val parsed = Routing.ADAPTER.decode(data.payload)
Logger.d { debugMsg.format(parsed.error_reason?.name) }
if (parsed.error_reason != Routing.Error.NONE) {
sendError(getStringResFrom(parsed.error_reason?.value ?: 0))
} else if (packet.from == destNum && route.isEmpty()) {
requestIds.update { it - data.requestId }
requestIds.update { it.apply { remove(data.request_id) } }
if (requestIds.value.isEmpty()) {
setResponseStateSuccess()
} else {
@ -702,91 +702,125 @@ constructor(
}
}
}
if (data?.portnumValue == Portnums.PortNum.ADMIN_APP_VALUE) {
val parsed = AdminProtos.AdminMessage.parseFrom(data.payload)
Logger.d { debugMsg.format(parsed.payloadVariantCase.name) }
if (data.portnum == PortNum.ADMIN_APP) {
val parsed = AdminMessage.ADAPTER.decode(data.payload)
// Explicitly log the non-null field name for clarity
val variant =
when {
parsed.get_device_metadata_response != null -> "get_device_metadata_response"
parsed.get_channel_response != null -> "get_channel_response"
parsed.get_owner_response != null -> "get_owner_response"
parsed.get_config_response != null -> "get_config_response"
parsed.get_module_config_response != null -> "get_module_config_response"
parsed.get_canned_message_module_messages_response != null ->
"get_canned_message_module_messages_response"
parsed.get_ringtone_response != null -> "get_ringtone_response"
parsed.get_device_connection_status_response != null -> "get_device_connection_status_response"
else -> "unknown"
}
Logger.d { debugMsg.format(variant) }
if (destNum != packet.from) {
sendError("Unexpected sender: ${packet.from.toUInt()} instead of ${destNum.toUInt()}.")
return
}
when (parsed.payloadVariantCase) {
AdminProtos.AdminMessage.PayloadVariantCase.GET_DEVICE_METADATA_RESPONSE -> {
_radioConfigState.update { it.copy(metadata = parsed.getDeviceMetadataResponse) }
when {
parsed.get_device_metadata_response != null -> {
_radioConfigState.update { it.copy(metadata = parsed.get_device_metadata_response) }
incrementCompleted()
}
AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> {
val response = parsed.getChannelResponse
parsed.get_channel_response != null -> {
val response = parsed.get_channel_response!!
// Stop once we get to the first disabled entry
if (response.role != ChannelProtos.Channel.Role.DISABLED) {
if (response.role != Channel.Role.DISABLED) {
_radioConfigState.update { state ->
state.copy(
channelList =
state.channelList.toMutableList().apply { add(response.index, response.settings) },
state.channelList.toMutableList().apply {
val index = response.index ?: 0
val settings = response.settings ?: ChannelSettings()
// Make sure list is large enough
while (size <= index) add(ChannelSettings())
set(index, settings)
},
)
}
incrementCompleted(
getString(Res.string.fetching_channel_indexed, response.index + 1, maxChannels),
)
if (response.index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) {
incrementCompleted()
val index = response.index ?: 0
if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) {
// Not done yet, request next channel
getChannel(destNum, response.index + 1)
getChannel(destNum, index + 1)
}
} else {
// Received last channel, update total and start channel editor
setResponseStateTotal(response.index + 1)
setResponseStateTotal((response.index ?: 0) + 1)
}
}
AdminProtos.AdminMessage.PayloadVariantCase.GET_OWNER_RESPONSE -> {
_radioConfigState.update { it.copy(userConfig = parsed.getOwnerResponse) }
parsed.get_owner_response != null -> {
_radioConfigState.update { it.copy(userConfig = parsed.get_owner_response!!) }
incrementCompleted()
}
AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE -> {
val response = parsed.getConfigResponse
if (response.payloadVariantCase.number == 0) { // PAYLOADVARIANT_NOT_SET
sendError(response.payloadVariantCase.name)
}
parsed.get_config_response != null -> {
val response = parsed.get_config_response!!
_radioConfigState.update { it.copy(radioConfig = response) }
incrementCompleted()
}
AdminProtos.AdminMessage.PayloadVariantCase.GET_MODULE_CONFIG_RESPONSE -> {
val response = parsed.getModuleConfigResponse
if (response.payloadVariantCase.number == 0) { // PAYLOADVARIANT_NOT_SET
sendError(response.payloadVariantCase.name)
parsed.get_module_config_response != null -> {
val response = parsed.get_module_config_response!!
_radioConfigState.update { state ->
state.copy(
moduleConfig =
state.moduleConfig.copy(
mqtt = response.mqtt ?: state.moduleConfig.mqtt,
serial = response.serial ?: state.moduleConfig.serial,
external_notification =
response.external_notification ?: state.moduleConfig.external_notification,
store_forward = response.store_forward ?: state.moduleConfig.store_forward,
range_test = response.range_test ?: state.moduleConfig.range_test,
telemetry = response.telemetry ?: state.moduleConfig.telemetry,
canned_message = response.canned_message ?: state.moduleConfig.canned_message,
audio = response.audio ?: state.moduleConfig.audio,
remote_hardware = response.remote_hardware ?: state.moduleConfig.remote_hardware,
neighbor_info = response.neighbor_info ?: state.moduleConfig.neighbor_info,
ambient_lighting = response.ambient_lighting ?: state.moduleConfig.ambient_lighting,
detection_sensor = response.detection_sensor ?: state.moduleConfig.detection_sensor,
paxcounter = response.paxcounter ?: state.moduleConfig.paxcounter,
statusmessage = response.statusmessage ?: state.moduleConfig.statusmessage,
),
)
}
_radioConfigState.update { it.copy(moduleConfig = response) }
incrementCompleted()
}
AdminProtos.AdminMessage.PayloadVariantCase.GET_CANNED_MESSAGE_MODULE_MESSAGES_RESPONSE -> {
parsed.get_canned_message_module_messages_response != null -> {
_radioConfigState.update {
it.copy(cannedMessageMessages = parsed.getCannedMessageModuleMessagesResponse)
it.copy(cannedMessageMessages = parsed.get_canned_message_module_messages_response!!)
}
incrementCompleted()
}
AdminProtos.AdminMessage.PayloadVariantCase.GET_RINGTONE_RESPONSE -> {
_radioConfigState.update { it.copy(ringtone = parsed.getRingtoneResponse) }
parsed.get_ringtone_response != null -> {
_radioConfigState.update { it.copy(ringtone = parsed.get_ringtone_response!!) }
incrementCompleted()
}
AdminProtos.AdminMessage.PayloadVariantCase.GET_DEVICE_CONNECTION_STATUS_RESPONSE -> {
parsed.get_device_connection_status_response != null -> {
_radioConfigState.update {
it.copy(deviceConnectionStatus = parsed.getDeviceConnectionStatusResponse)
it.copy(deviceConnectionStatus = parsed.get_device_connection_status_response)
}
incrementCompleted()
}
else -> Logger.d { "No custom processing needed for ${parsed.payloadVariantCase}" }
else -> Logger.d { "No custom processing needed for $parsed" }
}
if (AdminRoute.entries.any { it.name == route }) {
sendAdminRequest(destNum)
}
requestIds.update { it - data.requestId }
requestIds.update { it.apply { remove(data.request_id) } }
}
}
}

View file

@ -73,9 +73,8 @@ 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.component.PacketResponseStateDialog
import org.meshtastic.proto.ChannelProtos.ChannelSettings
import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig
import org.meshtastic.proto.channelSettings
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config
@Composable
fun ChannelConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
@ -89,9 +88,9 @@ fun ChannelConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
title = stringResource(Res.string.channels),
onBack = onBack,
settingsList = state.channelList,
loraConfig = state.radioConfig.lora,
loraConfig = state.radioConfig.lora ?: Config.LoRaConfig(),
maxChannels = viewModel.maxChannels,
firmwareVersion = state.metadata?.firmwareVersion ?: "0.0.0",
firmwareVersion = state.metadata?.firmware_version ?: "0.0.0",
enabled = state.connected,
onPositiveClicked = { channelListInput -> viewModel.updateChannels(channelListInput, state.channelList) },
)
@ -103,7 +102,7 @@ private fun ChannelConfigScreen(
title: String,
onBack: () -> Unit,
settingsList: List<ChannelSettings>,
loraConfig: LoRaConfig,
loraConfig: Config.LoRaConfig,
maxChannels: Int = 8,
firmwareVersion: String,
enabled: Boolean,
@ -138,7 +137,7 @@ private fun ChannelConfigScreen(
if (showEditChannelDialog != null) {
val index = showEditChannelDialog ?: return
EditChannelDialog(
channelSettings = with(settingsListInput) { if (size > index) get(index) else channelSettings {} },
channelSettings = with(settingsListInput) { if (size > index) get(index) else ChannelSettings() },
modemPresetName = modemPresetName,
onAddClick = {
if (settingsListInput.size > index) {
@ -173,7 +172,7 @@ private fun ChannelConfigScreen(
FloatingActionButton(
onClick = {
if (maxChannels > settingsListInput.size) {
settingsListInput.add(channelSettings { psk = Channel.default.settings.psk })
settingsListInput.add(ChannelSettings(psk = Channel.default.settings.psk))
showEditChannelDialog = settingsListInput.lastIndex
}
},
@ -188,14 +187,14 @@ private fun ChannelConfigScreen(
Column {
ChannelConfigHeader(
frequency =
if (loraConfig.overrideFrequency != 0f) {
loraConfig.overrideFrequency
if (loraConfig.override_frequency != 0f) {
loraConfig.override_frequency
} else {
primaryChannel.radioFreq
},
slot =
if (loraConfig.channelNum != 0) {
loraConfig.channelNum
if (loraConfig.channel_num != 0) {
loraConfig.channel_num
} else {
primaryChannel.channelNum
},
@ -282,7 +281,7 @@ private fun determineLocationSharingChannel(capabilities: Capabilities, settings
if (capabilities.supportsSecondaryChannelLocation) {
/* Essentially the first index with the setting enabled */
for ((i, settings) in settingsList.withIndex()) {
if (settings.moduleSettings.positionPrecision > 0) {
if ((settings.module_settings?.position_precision ?: 0) > 0) {
output = i
break
}
@ -290,7 +289,7 @@ private fun determineLocationSharingChannel(capabilities: Capabilities, settings
} else {
/* Only the primary channel at index 0 can share locations automatically */
val primary = settingsList[0]
if (primary.moduleSettings.positionPrecision > 0) {
if ((primary.module_settings?.position_precision ?: 0) > 0) {
output = 0
}
}
@ -305,11 +304,8 @@ private fun ChannelConfigScreenPreview() {
onBack = {},
settingsList =
listOf(
channelSettings {
psk = Channel.default.settings.psk
name = Channel.default.name
},
channelSettings { name = stringResource(Res.string.channel_name) },
ChannelSettings(psk = Channel.default.settings.psk, name = Channel.default.name),
ChannelSettings(name = stringResource(Res.string.channel_name)),
),
loraConfig = Channel.default.loraConfig,
firmwareVersion = "1.3.2",

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.channel.component
import androidx.compose.foundation.layout.Spacer
@ -35,10 +34,8 @@ import org.meshtastic.core.strings.delete
import org.meshtastic.core.ui.component.ChannelItem
import org.meshtastic.core.ui.component.SecurityIcon
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.ChannelProtos.ChannelSettings
import org.meshtastic.proto.ConfigKt.loRaConfig
import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig
import org.meshtastic.proto.channelSettings
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config
@Composable
internal fun ChannelCard(
@ -46,7 +43,7 @@ internal fun ChannelCard(
title: String,
enabled: Boolean,
channelSettings: ChannelSettings,
loraConfig: LoRaConfig,
loraConfig: Config.LoRaConfig,
onEditClick: () -> Unit,
onDeleteClick: () -> Unit,
sharesLocation: Boolean,
@ -58,14 +55,14 @@ internal fun ChannelCard(
modifier = Modifier.wrapContentSize().padding(horizontal = 5.dp),
)
}
if (channelSettings.uplinkEnabled) {
if (channelSettings.uplink_enabled) {
Icon(
imageVector = ChannelIcons.UPLINK.icon,
contentDescription = stringResource(ChannelIcons.UPLINK.descriptionResId),
modifier = Modifier.wrapContentSize().padding(horizontal = 5.dp),
)
}
if (channelSettings.downlinkEnabled) {
if (channelSettings.downlink_enabled) {
Icon(
imageVector = ChannelIcons.DOWNLINK.icon,
contentDescription = stringResource(ChannelIcons.DOWNLINK.descriptionResId),
@ -91,12 +88,8 @@ private fun ChannelCardPreview() {
index = 0,
title = "Medium Fast",
enabled = true,
channelSettings =
channelSettings {
uplinkEnabled = true
downlinkEnabled = true
},
loraConfig = loRaConfig {},
channelSettings = ChannelSettings(uplink_enabled = true, downlink_enabled = true),
loraConfig = Config.LoRaConfig(),
onEditClick = {},
onDeleteClick = {},
sharesLocation = true,

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.channel.component
import androidx.compose.foundation.layout.Arrangement
@ -54,15 +53,14 @@ import org.meshtastic.core.ui.component.EditBase64Preference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.PositionPrecisionPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.proto.ChannelProtos
import org.meshtastic.proto.channelSettings
import org.meshtastic.proto.copy
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.ModuleSettings
@Suppress("LongMethod")
@Composable
fun EditChannelDialog(
channelSettings: ChannelProtos.ChannelSettings,
onAddClick: (ChannelProtos.ChannelSettings) -> Unit,
channelSettings: ChannelSettings,
onAddClick: (ChannelSettings) -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
modemPresetName: String = stringResource(Res.string.default_),
@ -76,10 +74,15 @@ fun EditChannelDialog(
onDismissRequest = onDismissRequest,
shape = RoundedCornerShape(16.dp),
text = {
Column(modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth()) {
EditTextPreference(
title = stringResource(Res.string.channel_name),
value = if (isFocused) channelInput.name else channelInput.name.ifEmpty { modemPresetName },
value =
if (isFocused) {
(channelInput.name ?: "")
} else {
(channelInput.name ?: "").ifEmpty { modemPresetName }
},
maxSize = 11, // name max_size:12
enabled = true,
isError = false,
@ -87,51 +90,55 @@ fun EditChannelDialog(
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
channelInput =
channelInput.copy {
name = it.trim()
if (psk == Channel.default.settings.psk) psk = Channel.getRandomKey()
val defaultPsk = Channel.default.settings.psk
val newPsk =
if (channelInput.psk == defaultPsk) {
Channel.getRandomKey()
} else {
(channelInput.psk ?: okio.ByteString.EMPTY)
}
channelInput = channelInput.copy(name = it.trim(), psk = newPsk)
},
onFocusChanged = { isFocused = it.isFocused },
)
EditBase64Preference(
title = "PSK",
value = channelInput.psk,
value = channelInput.psk ?: okio.ByteString.EMPTY,
enabled = true,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChange = {
val fullPsk = Channel(channelSettings { psk = it }).psk
if (fullPsk.size() in setOf(0, 16, 32)) {
channelInput = channelInput.copy { psk = it }
val fullPsk = Channel(ChannelSettings(psk = it)).psk
if (fullPsk.size in setOf(0, 16, 32)) {
channelInput = channelInput.copy(psk = it)
}
},
onGenerateKey = { channelInput = channelInput.copy { psk = Channel.getRandomKey() } },
onGenerateKey = { channelInput = channelInput.copy(psk = Channel.getRandomKey()) },
)
SwitchPreference(
title = stringResource(Res.string.uplink_enabled),
checked = channelInput.uplinkEnabled,
checked = channelInput.uplink_enabled ?: false,
enabled = true,
onCheckedChange = { channelInput = channelInput.copy { uplinkEnabled = it } },
onCheckedChange = { channelInput = channelInput.copy(uplink_enabled = it) },
padding = PaddingValues(0.dp),
)
SwitchPreference(
title = stringResource(Res.string.downlink_enabled),
checked = channelInput.downlinkEnabled,
checked = channelInput.downlink_enabled ?: false,
enabled = true,
onCheckedChange = { channelInput = channelInput.copy { downlinkEnabled = it } },
onCheckedChange = { channelInput = channelInput.copy(downlink_enabled = it) },
padding = PaddingValues(0.dp),
)
val moduleSettings = channelInput.module_settings ?: ModuleSettings()
PositionPrecisionPreference(
enabled = true,
value = channelInput.moduleSettings.positionPrecision,
value = moduleSettings.position_precision ?: 0,
onValueChanged = {
val module = channelInput.moduleSettings.copy { positionPrecision = it }
channelInput = channelInput.copy { moduleSettings = module }
val updatedModule = moduleSettings.copy(position_precision = it)
channelInput = channelInput.copy(module_settings = updatedModule)
},
)
}
@ -156,11 +163,7 @@ fun EditChannelDialog(
@Composable
private fun EditChannelDialogPreview() {
EditChannelDialog(
channelSettings =
channelSettings {
psk = Channel.default.settings.psk
name = Channel.default.name
},
channelSettings = ChannelSettings(psk = Channel.default.settings.psk, name = Channel.default.name),
onAddClick = {},
onDismissRequest = {},
)

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.foundation.text.KeyboardActions
@ -38,13 +37,12 @@ import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.copy
import org.meshtastic.proto.moduleConfig
import org.meshtastic.proto.ModuleConfig
@Composable
fun AmbientLightingConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val ambientLightingConfig = state.moduleConfig.ambientLighting
val ambientLightingConfig = state.moduleConfig.ambient_lighting ?: ModuleConfig.AmbientLightingConfig()
val formState = rememberConfigState(initialValue = ambientLightingConfig)
val focusManager = LocalFocusManager.current
@ -56,7 +54,7 @@ fun AmbientLightingConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { ambientLighting = it }
val config = ModuleConfig(ambient_lighting = it)
viewModel.setModuleConfig(config)
},
) {
@ -64,40 +62,40 @@ fun AmbientLightingConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(
TitledCard(title = stringResource(Res.string.ambient_lighting_config)) {
SwitchPreference(
title = stringResource(Res.string.led_state),
checked = formState.value.ledState,
checked = formState.value.led_state ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { ledState = it } },
onCheckedChange = { formState.value = formState.value.copy(led_state = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.current),
value = formState.value.current,
value = formState.value.current ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { current = it } },
onValueChanged = { formState.value = formState.value.copy(current = it) },
)
EditTextPreference(
title = stringResource(Res.string.red),
value = formState.value.red,
value = formState.value.red ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { red = it } },
onValueChanged = { formState.value = formState.value.copy(red = it) },
)
EditTextPreference(
title = stringResource(Res.string.green),
value = formState.value.green,
value = formState.value.green ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { green = it } },
onValueChanged = { formState.value = formState.value.copy(green = it) },
)
EditTextPreference(
title = stringResource(Res.string.blue),
value = formState.value.blue,
value = formState.value.blue ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { blue = it } },
onValueChanged = { formState.value = formState.value.copy(blue = it) },
)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.foundation.text.KeyboardActions
@ -41,14 +40,12 @@ import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.ModuleConfigProtos.ModuleConfig.AudioConfig
import org.meshtastic.proto.copy
import org.meshtastic.proto.moduleConfig
import org.meshtastic.proto.ModuleConfig
@Composable
fun AudioConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val audioConfig = state.moduleConfig.audio
val audioConfig = state.moduleConfig.audio ?: ModuleConfig.AudioConfig()
val formState = rememberConfigState(initialValue = audioConfig)
val focusManager = LocalFocusManager.current
@ -60,7 +57,7 @@ fun AudioConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack:
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { audio = it }
val config = ModuleConfig(audio = it)
viewModel.setModuleConfig(config)
},
) {
@ -68,57 +65,54 @@ fun AudioConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack:
TitledCard(title = stringResource(Res.string.audio_config)) {
SwitchPreference(
title = stringResource(Res.string.codec_2_enabled),
checked = formState.value.codec2Enabled,
checked = formState.value.codec2_enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { codec2Enabled = it } },
onCheckedChange = { formState.value = formState.value.copy(codec2_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.ptt_pin),
value = formState.value.pttPin,
value = formState.value.ptt_pin ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { pttPin = it } },
onValueChanged = { formState.value = formState.value.copy(ptt_pin = it) },
)
DropDownPreference(
title = stringResource(Res.string.codec2_sample_rate),
enabled = state.connected,
items =
AudioConfig.Audio_Baud.entries
.filter { it != AudioConfig.Audio_Baud.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.bitrate,
onItemSelected = { formState.value = formState.value.copy { bitrate = it } },
items = ModuleConfig.AudioConfig.Audio_Baud.entries.map { it to it.name },
selectedItem = formState.value.bitrate ?: ModuleConfig.AudioConfig.Audio_Baud.CODEC2_DEFAULT,
onItemSelected = { formState.value = formState.value.copy(bitrate = it) },
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.i2s_word_select),
value = formState.value.i2SWs,
value = formState.value.i2s_ws ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { i2SWs = it } },
onValueChanged = { formState.value = formState.value.copy(i2s_ws = it) },
)
EditTextPreference(
title = stringResource(Res.string.i2s_data_in),
value = formState.value.i2SSd,
value = formState.value.i2s_sd ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { i2SSd = it } },
onValueChanged = { formState.value = formState.value.copy(i2s_sd = it) },
)
EditTextPreference(
title = stringResource(Res.string.i2s_data_out),
value = formState.value.i2SDin,
value = formState.value.i2s_din ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { i2SDin = it } },
onValueChanged = { formState.value = formState.value.copy(i2s_din = it) },
)
EditTextPreference(
title = stringResource(Res.string.i2s_clock),
value = formState.value.i2SSck,
value = formState.value.i2s_sck ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { i2SSck = it } },
onValueChanged = { formState.value = formState.value.copy(i2s_sck = it) },
)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.foundation.text.KeyboardActions
@ -37,14 +36,12 @@ import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.ConfigProtos.Config.BluetoothConfig
import org.meshtastic.proto.config
import org.meshtastic.proto.copy
import org.meshtastic.proto.Config
@Composable
fun BluetoothConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val bluetoothConfig = state.radioConfig.bluetooth
val bluetoothConfig = state.radioConfig.bluetooth ?: Config.BluetoothConfig()
val formState = rememberConfigState(initialValue = bluetoothConfig)
val focusManager = LocalFocusManager.current
@ -56,7 +53,7 @@ fun BluetoothConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onB
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = config { bluetooth = it }
val config = Config(bluetooth = it)
viewModel.setConfig(config)
},
) {
@ -66,7 +63,7 @@ fun BluetoothConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onB
title = stringResource(Res.string.bluetooth_enabled),
checked = formState.value.enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } },
onCheckedChange = { formState.value = formState.value.copy(enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
@ -74,21 +71,23 @@ fun BluetoothConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onB
title = stringResource(Res.string.pairing_mode),
enabled = state.connected,
items =
BluetoothConfig.PairingMode.entries
.filter { it != BluetoothConfig.PairingMode.UNRECOGNIZED }
Config.BluetoothConfig.PairingMode.entries
.filter { it.name != "UNRECOGNIZED" }
.map { it to it.name },
selectedItem = formState.value.mode,
onItemSelected = { formState.value = formState.value.copy { mode = it } },
selectedItem =
formState.value.mode?.takeUnless { it.name == "UNRECOGNIZED" }
?: Config.BluetoothConfig.PairingMode.RANDOM_PIN,
onItemSelected = { formState.value = formState.value.copy(mode = it) },
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.fixed_pin),
value = formState.value.fixedPin,
value = formState.value.fixed_pin ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
if (it.toString().length == 6) { // ensure 6 digits
formState.value = formState.value.copy { fixedPin = it }
formState.value = formState.value.copy(fixed_pin = it)
}
},
)

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.foundation.text.KeyboardActions
@ -52,14 +51,12 @@ import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.ModuleConfigProtos.ModuleConfig.CannedMessageConfig
import org.meshtastic.proto.copy
import org.meshtastic.proto.moduleConfig
import org.meshtastic.proto.ModuleConfig
@Composable
fun CannedMessageConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val cannedMessageConfig = state.moduleConfig.cannedMessage
val cannedMessageConfig = state.moduleConfig.canned_message ?: ModuleConfig.CannedMessageConfig()
val messages = state.cannedMessageMessages
val formState = rememberConfigState(initialValue = cannedMessageConfig)
var messagesInput by rememberSaveable(messages) { mutableStateOf(messages) }
@ -79,7 +76,7 @@ fun CannedMessageConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(),
viewModel.setCannedMessages(messagesInput)
}
if (formState.value != cannedMessageConfig) {
val config = moduleConfig { cannedMessage = formState.value }
val config = ModuleConfig(canned_message = formState.value)
viewModel.setModuleConfig(config)
}
},
@ -90,96 +87,90 @@ fun CannedMessageConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(),
title = stringResource(Res.string.canned_message_enabled),
checked = formState.value.enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } },
onCheckedChange = { formState.value = formState.value.copy(enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.rotary_encoder_1_enabled),
checked = formState.value.rotary1Enabled,
checked = formState.value.rotary1_enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { rotary1Enabled = it } },
onCheckedChange = { formState.value = formState.value.copy(rotary1_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.gpio_pin_for_rotary_encoder_a_port),
value = formState.value.inputbrokerPinA,
value = formState.value.inputbroker_pin_a ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { inputbrokerPinA = it } },
onValueChanged = { formState.value = formState.value.copy(inputbroker_pin_a = it) },
)
EditTextPreference(
title = stringResource(Res.string.gpio_pin_for_rotary_encoder_b_port),
value = formState.value.inputbrokerPinB,
value = formState.value.inputbroker_pin_b ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { inputbrokerPinB = it } },
onValueChanged = { formState.value = formState.value.copy(inputbroker_pin_b = it) },
)
EditTextPreference(
title = stringResource(Res.string.gpio_pin_for_rotary_encoder_press_port),
value = formState.value.inputbrokerPinPress,
value = formState.value.inputbroker_pin_press ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { inputbrokerPinPress = it } },
onValueChanged = { formState.value = formState.value.copy(inputbroker_pin_press = it) },
)
DropDownPreference(
title = stringResource(Res.string.generate_input_event_on_press),
enabled = state.connected,
items =
CannedMessageConfig.InputEventChar.entries
.filter { it != CannedMessageConfig.InputEventChar.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.inputbrokerEventPress,
onItemSelected = { formState.value = formState.value.copy { inputbrokerEventPress = it } },
items = ModuleConfig.CannedMessageConfig.InputEventChar.entries.map { it to it.name },
selectedItem =
formState.value.inputbroker_event_press ?: ModuleConfig.CannedMessageConfig.InputEventChar.NONE,
onItemSelected = { formState.value = formState.value.copy(inputbroker_event_press = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.generate_input_event_on_cw),
enabled = state.connected,
items =
CannedMessageConfig.InputEventChar.entries
.filter { it != CannedMessageConfig.InputEventChar.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.inputbrokerEventCw,
onItemSelected = { formState.value = formState.value.copy { inputbrokerEventCw = it } },
items = ModuleConfig.CannedMessageConfig.InputEventChar.entries.map { it to it.name },
selectedItem =
formState.value.inputbroker_event_cw ?: ModuleConfig.CannedMessageConfig.InputEventChar.NONE,
onItemSelected = { formState.value = formState.value.copy(inputbroker_event_cw = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.generate_input_event_on_ccw),
enabled = state.connected,
items =
CannedMessageConfig.InputEventChar.entries
.filter { it != CannedMessageConfig.InputEventChar.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.inputbrokerEventCcw,
onItemSelected = { formState.value = formState.value.copy { inputbrokerEventCcw = it } },
items = ModuleConfig.CannedMessageConfig.InputEventChar.entries.map { it to it.name },
selectedItem =
formState.value.inputbroker_event_ccw ?: ModuleConfig.CannedMessageConfig.InputEventChar.NONE,
onItemSelected = { formState.value = formState.value.copy(inputbroker_event_ccw = it) },
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.up_down_select_input_enabled),
checked = formState.value.updown1Enabled,
checked = formState.value.updown1_enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { updown1Enabled = it } },
onCheckedChange = { formState.value = formState.value.copy(updown1_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.allow_input_source),
value = formState.value.allowInputSource,
value = formState.value.allow_input_source ?: "",
maxSize = 63, // allow_input_source max_size:16
enabled = state.connected,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { allowInputSource = it } },
onValueChanged = { formState.value = formState.value.copy(allow_input_source = it) },
)
SwitchPreference(
title = stringResource(Res.string.send_bell),
checked = formState.value.sendBell,
checked = formState.value.send_bell ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { sendBell = it } },
onCheckedChange = { formState.value = formState.value.copy(send_bell = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.runtime.Composable
@ -23,7 +22,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.google.protobuf.MessageLite
import com.squareup.wire.Message
/**
* A state holder for managing config data within a Composable.
@ -32,10 +31,10 @@ import com.google.protobuf.MessageLite
* whether the current value has been modified ("dirty"), and provides simple methods to save the changes or reset to
* the initial state.
*
* @param T The type of the data being managed, typically a Protobuf message.
* @param T The type of the data being managed, typically a Wire message.
* @property initialValue The original, unmodified value of the config data.
*/
class ConfigState<T : MessageLite>(private val initialValue: T) {
class ConfigState<T : Message<T, *>>(private val initialValue: T) {
var value by mutableStateOf(initialValue)
val isDirty: Boolean
@ -46,14 +45,9 @@ class ConfigState<T : MessageLite>(private val initialValue: T) {
}
companion object {
fun <T : MessageLite> saver(initialValue: T): Saver<ConfigState<T>, ByteArray> = Saver(
save = { it.value.toByteArray() },
restore = {
ConfigState(initialValue).apply {
@Suppress("UNCHECKED_CAST")
value = initialValue.parserForType.parseFrom(it) as T
}
},
fun <T : Message<T, *>> saver(initialValue: T): Saver<ConfigState<T>, ByteArray> = Saver(
save = { it.value.adapter.encode(it.value) },
restore = { ConfigState(initialValue).apply { value = initialValue.adapter.decode(it) } },
)
}
}
@ -66,5 +60,5 @@ class ConfigState<T : MessageLite>(private val initialValue: T) {
* across recompositions.
*/
@Composable
fun <T : MessageLite> rememberConfigState(initialValue: T): ConfigState<T> =
fun <T : Message<T, *>> rememberConfigState(initialValue: T): ConfigState<T> =
rememberSaveable(initialValue, saver = ConfigState.saver(initialValue)) { ConfigState(initialValue) }

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.foundation.text.KeyboardActions
@ -49,14 +48,12 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.util.IntervalConfiguration
import org.meshtastic.feature.settings.util.gpioPins
import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.ModuleConfigProtos.ModuleConfig
import org.meshtastic.proto.copy
import org.meshtastic.proto.moduleConfig
import org.meshtastic.proto.ModuleConfig
@Composable
fun DetectionSensorConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val detectionSensorConfig = state.moduleConfig.detectionSensor
val detectionSensorConfig = state.moduleConfig.detection_sensor ?: ModuleConfig.DetectionSensorConfig()
val formState = rememberConfigState(initialValue = detectionSensorConfig)
val focusManager = LocalFocusManager.current
@ -68,7 +65,7 @@ fun DetectionSensorConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { detectionSensor = it }
val config = ModuleConfig(detection_sensor = it)
viewModel.setModuleConfig(config)
},
) {
@ -78,7 +75,7 @@ fun DetectionSensorConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(
title = stringResource(Res.string.detection_sensor_enabled),
checked = formState.value.enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } },
onCheckedChange = { formState.value = formState.value.copy(enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
@ -87,66 +84,65 @@ fun DetectionSensorConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(
}
DropDownPreference(
title = stringResource(Res.string.minimum_broadcast_seconds),
selectedItem = formState.value.minimumBroadcastSecs.toLong(),
selectedItem = (formState.value.minimum_broadcast_secs ?: 0).toLong(),
enabled = state.connected,
items = minimumBroadcastIntervals.map { it.value to it.toDisplayString() },
onItemSelected = { formState.value = formState.value.copy { minimumBroadcastSecs = it.toInt() } },
onItemSelected = { formState.value = formState.value.copy(minimum_broadcast_secs = it.toInt()) },
)
val stateBroadcastIntervals = remember { IntervalConfiguration.DETECTION_SENSOR_STATE.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.state_broadcast_seconds),
selectedItem = formState.value.stateBroadcastSecs.toLong(),
selectedItem = (formState.value.state_broadcast_secs ?: 0).toLong(),
enabled = state.connected,
items = stateBroadcastIntervals.map { it.value to it.toDisplayString() },
onItemSelected = { formState.value = formState.value.copy { stateBroadcastSecs = it.toInt() } },
onItemSelected = { formState.value = formState.value.copy(state_broadcast_secs = it.toInt()) },
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.send_bell_with_alert_message),
checked = formState.value.sendBell,
checked = formState.value.send_bell,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { sendBell = it } },
onCheckedChange = { formState.value = formState.value.copy(send_bell = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.friendly_name),
value = formState.value.name,
value = formState.value.name ?: "",
maxSize = 19, // name max_size:20
enabled = state.connected,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { name = it } },
onValueChanged = { formState.value = formState.value.copy(name = it) },
)
HorizontalDivider()
val pins = remember { gpioPins }
DropDownPreference(
title = stringResource(Res.string.gpio_pin_to_monitor),
items = pins,
selectedItem = formState.value.monitorPin,
selectedItem = formState.value.monitor_pin ?: 0,
enabled = state.connected,
onItemSelected = { formState.value = formState.value.copy { monitorPin = it } },
onItemSelected = { formState.value = formState.value.copy(monitor_pin = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.detection_trigger_type),
enabled = state.connected,
items =
ModuleConfig.DetectionSensorConfig.TriggerType.entries
.filter { it != ModuleConfig.DetectionSensorConfig.TriggerType.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.detectionTriggerType,
onItemSelected = { formState.value = formState.value.copy { detectionTriggerType = it } },
items = ModuleConfig.DetectionSensorConfig.TriggerType.entries.map { it to it.name },
selectedItem =
formState.value.detection_trigger_type
?: ModuleConfig.DetectionSensorConfig.TriggerType.LOGIC_LOW,
onItemSelected = { formState.value = formState.value.copy(detection_trigger_type = it) },
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.use_input_pullup_mode),
checked = formState.value.usePullup,
checked = formState.value.use_pullup ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { usePullup = it } },
onCheckedChange = { formState.value = formState.value.copy(use_pullup = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()

View file

@ -119,39 +119,38 @@ import org.meshtastic.core.ui.timezone.toPosixString
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.util.IntervalConfiguration
import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.ConfigProtos.Config.DeviceConfig
import org.meshtastic.proto.config
import org.meshtastic.proto.copy
import org.meshtastic.proto.Config
import java.time.ZoneId
private val DeviceConfig.Role.description: StringResource
private val Config.DeviceConfig.Role.description: StringResource
get() =
when (this) {
DeviceConfig.Role.CLIENT -> Res.string.role_client_desc
DeviceConfig.Role.CLIENT_BASE -> Res.string.role_client_base_desc
DeviceConfig.Role.CLIENT_MUTE -> Res.string.role_client_mute_desc
DeviceConfig.Role.ROUTER -> Res.string.role_router_desc
DeviceConfig.Role.ROUTER_CLIENT -> Res.string.role_router_client_desc
DeviceConfig.Role.REPEATER -> Res.string.role_repeater_desc
DeviceConfig.Role.TRACKER -> Res.string.role_tracker_desc
DeviceConfig.Role.SENSOR -> Res.string.role_sensor_desc
DeviceConfig.Role.TAK -> Res.string.role_tak_desc
DeviceConfig.Role.CLIENT_HIDDEN -> Res.string.role_client_hidden_desc
DeviceConfig.Role.LOST_AND_FOUND -> Res.string.role_lost_and_found_desc
DeviceConfig.Role.TAK_TRACKER -> Res.string.role_tak_tracker_desc
DeviceConfig.Role.ROUTER_LATE -> Res.string.role_router_late_desc
Config.DeviceConfig.Role.CLIENT -> Res.string.role_client_desc
Config.DeviceConfig.Role.CLIENT_BASE -> Res.string.role_client_base_desc
Config.DeviceConfig.Role.CLIENT_MUTE -> Res.string.role_client_mute_desc
Config.DeviceConfig.Role.ROUTER -> Res.string.role_router_desc
Config.DeviceConfig.Role.ROUTER_CLIENT -> Res.string.role_router_client_desc
Config.DeviceConfig.Role.REPEATER -> Res.string.role_repeater_desc
Config.DeviceConfig.Role.TRACKER -> Res.string.role_tracker_desc
Config.DeviceConfig.Role.SENSOR -> Res.string.role_sensor_desc
Config.DeviceConfig.Role.TAK -> Res.string.role_tak_desc
Config.DeviceConfig.Role.CLIENT_HIDDEN -> Res.string.role_client_hidden_desc
Config.DeviceConfig.Role.LOST_AND_FOUND -> Res.string.role_lost_and_found_desc
Config.DeviceConfig.Role.TAK_TRACKER -> Res.string.role_tak_tracker_desc
Config.DeviceConfig.Role.ROUTER_LATE -> Res.string.role_router_late_desc
else -> Res.string.unrecognized
}
private val DeviceConfig.RebroadcastMode.description: StringResource
private val Config.DeviceConfig.RebroadcastMode.description: StringResource
get() =
when (this) {
DeviceConfig.RebroadcastMode.ALL -> Res.string.rebroadcast_mode_all_desc
DeviceConfig.RebroadcastMode.ALL_SKIP_DECODING -> Res.string.rebroadcast_mode_all_skip_decoding_desc
DeviceConfig.RebroadcastMode.LOCAL_ONLY -> Res.string.rebroadcast_mode_local_only_desc
DeviceConfig.RebroadcastMode.KNOWN_ONLY -> Res.string.rebroadcast_mode_known_only_desc
DeviceConfig.RebroadcastMode.NONE -> Res.string.rebroadcast_mode_none_desc
DeviceConfig.RebroadcastMode.CORE_PORTNUMS_ONLY -> Res.string.rebroadcast_mode_core_portnums_only_desc
Config.DeviceConfig.RebroadcastMode.ALL -> Res.string.rebroadcast_mode_all_desc
Config.DeviceConfig.RebroadcastMode.ALL_SKIP_DECODING -> Res.string.rebroadcast_mode_all_skip_decoding_desc
Config.DeviceConfig.RebroadcastMode.LOCAL_ONLY -> Res.string.rebroadcast_mode_local_only_desc
Config.DeviceConfig.RebroadcastMode.KNOWN_ONLY -> Res.string.rebroadcast_mode_known_only_desc
Config.DeviceConfig.RebroadcastMode.NONE -> Res.string.rebroadcast_mode_none_desc
Config.DeviceConfig.RebroadcastMode.CORE_PORTNUMS_ONLY ->
Res.string.rebroadcast_mode_core_portnums_only_desc
else -> Res.string.unrecognized
}
@ -159,19 +158,19 @@ private val DeviceConfig.RebroadcastMode.description: StringResource
@Composable
fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val deviceConfig = state.radioConfig.device
val deviceConfig = state.radioConfig.device ?: Config.DeviceConfig()
val formState = rememberConfigState(initialValue = deviceConfig)
var selectedRole by rememberSaveable { mutableStateOf(formState.value.role) }
var selectedRole by rememberSaveable { mutableStateOf(formState.value.role ?: Config.DeviceConfig.Role.CLIENT) }
val infrastructureRoles =
listOf(DeviceConfig.Role.ROUTER, DeviceConfig.Role.ROUTER_LATE, DeviceConfig.Role.REPEATER)
listOf(Config.DeviceConfig.Role.ROUTER, Config.DeviceConfig.Role.ROUTER_LATE, Config.DeviceConfig.Role.REPEATER)
if (selectedRole != formState.value.role) {
if (selectedRole in infrastructureRoles) {
RouterRoleConfirmationDialog(
onDismiss = { selectedRole = formState.value.role },
onConfirm = { formState.value = formState.value.copy { role = selectedRole } },
onDismiss = { selectedRole = formState.value.role ?: Config.DeviceConfig.Role.CLIENT },
onConfirm = { formState.value = formState.value.copy(role = selectedRole) },
)
} else {
formState.value = formState.value.copy { role = selectedRole }
formState.value = formState.value.copy(role = selectedRole)
}
}
val focusManager = LocalFocusManager.current
@ -183,28 +182,30 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = config { device = it }
val config = Config(device = it)
viewModel.setConfig(config)
},
) {
item {
TitledCard(title = stringResource(Res.string.options)) {
val currentRole = formState.value.role ?: Config.DeviceConfig.Role.CLIENT
DropDownPreference(
title = stringResource(Res.string.role),
enabled = state.connected,
selectedItem = formState.value.role,
selectedItem = currentRole,
onItemSelected = { selectedRole = it },
summary = stringResource(formState.value.role.description),
summary = stringResource(currentRole.description),
)
HorizontalDivider()
val currentRebroadcastMode = formState.value.rebroadcast_mode ?: Config.DeviceConfig.RebroadcastMode.ALL
DropDownPreference(
title = stringResource(Res.string.rebroadcast_mode),
enabled = state.connected,
selectedItem = formState.value.rebroadcastMode,
onItemSelected = { formState.value = formState.value.copy { rebroadcastMode = it } },
summary = stringResource(formState.value.rebroadcastMode.description),
selectedItem = currentRebroadcastMode,
onItemSelected = { formState.value = formState.value.copy(rebroadcast_mode = it) },
summary = stringResource(currentRebroadcastMode.description),
)
HorizontalDivider()
@ -212,10 +213,10 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack
val nodeInfoBroadcastIntervals = remember { IntervalConfiguration.NODE_INFO_BROADCAST.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.nodeinfo_broadcast_interval),
selectedItem = formState.value.nodeInfoBroadcastSecs.toLong(),
selectedItem = (formState.value.node_info_broadcast_secs ?: 0).toLong(),
enabled = state.connected,
items = nodeInfoBroadcastIntervals.map { it.value to it.toDisplayString() },
onItemSelected = { formState.value = formState.value.copy { nodeInfoBroadcastSecs = it.toInt() } },
onItemSelected = { formState.value = formState.value.copy(node_info_broadcast_secs = it.toInt()) },
)
}
}
@ -225,9 +226,9 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack
SwitchPreference(
title = stringResource(Res.string.double_tap_as_button_press),
summary = stringResource(Res.string.config_device_doubleTapAsButtonPress_summary),
checked = formState.value.doubleTapAsButtonPress,
checked = formState.value.double_tap_as_button_press,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { doubleTapAsButtonPress = it } },
onCheckedChange = { formState.value = formState.value.copy(double_tap_as_button_press = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
@ -236,9 +237,9 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack
SwitchPreference(
title = stringResource(Res.string.triple_click_adhoc_ping),
summary = stringResource(Res.string.config_device_tripleClickAsAdHocPing_summary),
checked = !formState.value.disableTripleClick,
checked = !formState.value.disable_triple_click,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { disableTripleClick = !it } },
onCheckedChange = { formState.value = formState.value.copy(disable_triple_click = !it) },
containerColor = CardDefaults.cardColors().containerColor,
)
@ -247,9 +248,9 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack
SwitchPreference(
title = stringResource(Res.string.led_heartbeat),
summary = stringResource(Res.string.config_device_ledHeartbeatEnabled_summary),
checked = !formState.value.ledHeartbeatDisabled,
checked = !formState.value.led_heartbeat_disabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { ledHeartbeatDisabled = !it } },
onCheckedChange = { formState.value = formState.value.copy(led_heartbeat_disabled = !it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
@ -273,7 +274,7 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack
EditTextPreference(
title = "",
value = formState.value.tzdef,
value = formState.value.tzdef ?: "",
summary = stringResource(Res.string.config_device_tzdef_summary),
maxSize = 64, // tzdef max_size:65
enabled = state.connected,
@ -281,9 +282,9 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { tzdef = it } },
onValueChanged = { formState.value = formState.value.copy(tzdef = it) },
trailingIcon = {
IconButton(onClick = { formState.value = formState.value.copy { tzdef = "" } }) {
IconButton(onClick = { formState.value = formState.value.copy(tzdef = "") }) {
Icon(imageVector = Icons.Rounded.Clear, contentDescription = null)
}
},
@ -295,7 +296,7 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack
modifier = Modifier.height(MediumContainerHeight).fillMaxWidth(),
enabled = state.connected,
shape = RectangleShape,
onClick = { formState.value = formState.value.copy { tzdef = appTzPosixString } },
onClick = { formState.value = formState.value.copy(tzdef = appTzPosixString) },
) {
Icon(imageVector = Icons.Rounded.PhoneAndroid, contentDescription = null)
@ -310,20 +311,20 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack
TitledCard(title = stringResource(Res.string.gpio)) {
EditTextPreference(
title = stringResource(Res.string.button_gpio),
value = formState.value.buttonGpio,
value = formState.value.button_gpio ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { buttonGpio = it } },
onValueChanged = { formState.value = formState.value.copy(button_gpio = it) },
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.buzzer_gpio),
value = formState.value.buzzerGpio,
value = formState.value.buzzer_gpio ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { buzzerGpio = it } },
onValueChanged = { formState.value = formState.value.copy(buzzer_gpio = it) },
)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.material3.CardDefaults
@ -56,14 +55,12 @@ import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.util.IntervalConfiguration
import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig
import org.meshtastic.proto.config
import org.meshtastic.proto.copy
import org.meshtastic.proto.Config
@Composable
fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val displayConfig = state.radioConfig.display
val displayConfig = state.radioConfig.display ?: Config.DisplayConfig()
val formState = rememberConfigState(initialValue = displayConfig)
RadioConfigScreenList(
@ -74,7 +71,7 @@ fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = config { display = it }
val config = Config(display = it)
viewModel.setConfig(config)
},
) {
@ -83,9 +80,9 @@ fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
SwitchPreference(
title = stringResource(Res.string.always_point_north),
summary = stringResource(Res.string.config_display_compass_north_top_summary),
checked = formState.value.compassNorthTop,
checked = formState.value.compass_north_top ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { compassNorthTop = it } },
onCheckedChange = { formState.value = formState.value.copy(compass_north_top = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
@ -93,17 +90,17 @@ fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
title = stringResource(Res.string.use_12h_format),
summary = stringResource(Res.string.display_time_in_12h_format),
enabled = state.connected,
checked = formState.value.use12HClock,
onCheckedChange = { formState.value = formState.value.copy { use12HClock = it } },
checked = formState.value.use_12h_clock ?: false,
onCheckedChange = { formState.value = formState.value.copy(use_12h_clock = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.bold_heading),
summary = stringResource(Res.string.config_display_heading_bold_summary),
checked = formState.value.headingBold,
checked = formState.value.heading_bold ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { headingBold = it } },
onCheckedChange = { formState.value = formState.value.copy(heading_bold = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
@ -111,12 +108,9 @@ fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
title = stringResource(Res.string.display_units),
summary = stringResource(Res.string.config_display_units_summary),
enabled = state.connected,
items =
DisplayConfig.DisplayUnits.entries
.filter { it != DisplayConfig.DisplayUnits.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.units,
onItemSelected = { formState.value = formState.value.copy { units = it } },
items = Config.DisplayConfig.DisplayUnits.entries.map { it to it.name },
selectedItem = formState.value.units ?: Config.DisplayConfig.DisplayUnits.METRIC,
onItemSelected = { formState.value = formState.value.copy(units = it) },
)
}
}
@ -130,9 +124,9 @@ fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
enabled = state.connected,
items = screenOnIntervals.map { it to it.toDisplayString() },
selectedItem =
screenOnIntervals.find { it.value == formState.value.screenOnSecs.toLong() }
screenOnIntervals.find { it.value == (formState.value.screen_on_secs ?: 0).toLong() }
?: screenOnIntervals.first(),
onItemSelected = { formState.value = formState.value.copy { screenOnSecs = it.value.toInt() } },
onItemSelected = { formState.value = formState.value.copy(screen_on_secs = it.value.toInt()) },
)
HorizontalDivider()
DropDownPreference(
@ -141,28 +135,28 @@ fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
enabled = state.connected,
items = carouselIntervals.map { it to it.toDisplayString() },
selectedItem =
carouselIntervals.find { it.value == formState.value.autoScreenCarouselSecs.toLong() }
carouselIntervals.find { it.value == (formState.value.auto_screen_carousel_secs ?: 0).toLong() }
?: carouselIntervals.first(),
onItemSelected = {
formState.value = formState.value.copy { autoScreenCarouselSecs = it.value.toInt() }
formState.value = formState.value.copy(auto_screen_carousel_secs = it.value.toInt())
},
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.wake_on_tap_or_motion),
summary = stringResource(Res.string.config_display_wake_on_tap_or_motion_summary),
checked = formState.value.wakeOnTapOrMotion,
checked = formState.value.wake_on_tap_or_motion ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { wakeOnTapOrMotion = it } },
onCheckedChange = { formState.value = formState.value.copy(wake_on_tap_or_motion = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.flip_screen),
summary = stringResource(Res.string.config_display_flip_screen_summary),
checked = formState.value.flipScreen,
checked = formState.value.flip_screen ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { flipScreen = it } },
onCheckedChange = { formState.value = formState.value.copy(flip_screen = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
@ -170,35 +164,27 @@ fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
title = stringResource(Res.string.display_mode),
summary = stringResource(Res.string.config_display_displaymode_summary),
enabled = state.connected,
items =
DisplayConfig.DisplayMode.entries
.filter { it != DisplayConfig.DisplayMode.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.displaymode,
onItemSelected = { formState.value = formState.value.copy { displaymode = it } },
items = Config.DisplayConfig.DisplayMode.entries.map { it to it.name },
selectedItem = formState.value.displaymode ?: Config.DisplayConfig.DisplayMode.DEFAULT,
onItemSelected = { formState.value = formState.value.copy(displaymode = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.oled_type),
summary = stringResource(Res.string.config_display_oled_summary),
enabled = state.connected,
items =
DisplayConfig.OledType.entries
.filter { it != DisplayConfig.OledType.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.oled,
onItemSelected = { formState.value = formState.value.copy { oled = it } },
items = Config.DisplayConfig.OledType.entries.map { it to it.name },
selectedItem = formState.value.oled ?: Config.DisplayConfig.OledType.OLED_AUTO,
onItemSelected = { formState.value = formState.value.copy(oled = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.compass_orientation),
enabled = state.connected,
items =
DisplayConfig.CompassOrientation.entries
.filter { it != DisplayConfig.CompassOrientation.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.compassOrientation,
onItemSelected = { formState.value = formState.value.copy { compassOrientation = it } },
items = Config.DisplayConfig.CompassOrientation.entries.map { it to it.name },
selectedItem =
formState.value.compass_orientation ?: Config.DisplayConfig.CompassOrientation.DEGREES_0,
onItemSelected = { formState.value = formState.value.copy(compass_orientation = it) },
)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.foundation.layout.Arrangement
@ -39,15 +38,28 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.protobuf.Descriptors
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.channel_url
import org.meshtastic.core.strings.fixed_position
import org.meshtastic.core.strings.long_name
import org.meshtastic.core.strings.module_settings
import org.meshtastic.core.strings.radio_configuration
import org.meshtastic.core.strings.save
import org.meshtastic.core.strings.short_name
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.proto.ClientOnlyProtos.DeviceProfile
import org.meshtastic.proto.DeviceProfile
private const val SUPPORTED_FIELDS = 7
private enum class ProfileField(val tag: Int, val labelRes: StringResource) {
LONG_NAME(1, Res.string.long_name),
SHORT_NAME(2, Res.string.short_name),
CHANNEL_URL(3, Res.string.channel_url),
CONFIG(4, Res.string.radio_configuration),
MODULE_CONFIG(5, Res.string.module_settings),
FIXED_POSITION(6, Res.string.fixed_position),
}
@Suppress("LongMethod")
@OptIn(ExperimentalLayoutApi::class)
@ -60,12 +72,19 @@ fun EditDeviceProfileDialog(
modifier: Modifier = Modifier,
) {
val state = remember {
val fields =
deviceProfile.descriptorForType.fields.filter {
it.number < SUPPORTED_FIELDS
} // TODO add ringtone & canned messages
mutableStateMapOf<Descriptors.FieldDescriptor, Boolean>().apply {
putAll(fields.associateWith(deviceProfile::hasField))
mutableStateMapOf<ProfileField, Boolean>().apply {
putAll(
ProfileField.entries.associateWith { field ->
when (field) {
ProfileField.LONG_NAME -> deviceProfile.long_name != null
ProfileField.SHORT_NAME -> deviceProfile.short_name != null
ProfileField.CHANNEL_URL -> deviceProfile.channel_url != null
ProfileField.CONFIG -> deviceProfile.config != null
ProfileField.MODULE_CONFIG -> deviceProfile.module_config != null
ProfileField.FIXED_POSITION -> deviceProfile.fixed_position != null
}
},
)
}
}
@ -84,17 +103,24 @@ fun EditDeviceProfileDialog(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
)
HorizontalDivider()
state.keys
.sortedBy { it.number }
.forEach { field ->
SwitchPreference(
title = field.name,
checked = state[field] == true,
enabled = deviceProfile.hasField(field),
onCheckedChange = { state[field] = it },
padding = PaddingValues(0.dp),
)
}
ProfileField.entries.forEach { field ->
val isAvailable =
when (field) {
ProfileField.LONG_NAME -> deviceProfile.long_name != null
ProfileField.SHORT_NAME -> deviceProfile.short_name != null
ProfileField.CHANNEL_URL -> deviceProfile.channel_url != null
ProfileField.CONFIG -> deviceProfile.config != null
ProfileField.MODULE_CONFIG -> deviceProfile.module_config != null
ProfileField.FIXED_POSITION -> deviceProfile.fixed_position != null
}
SwitchPreference(
title = stringResource(field.labelRes),
checked = state[field] == true,
enabled = isAvailable,
onCheckedChange = { state[field] = it },
padding = PaddingValues(0.dp),
)
}
HorizontalDivider()
}
},
@ -109,13 +135,29 @@ fun EditDeviceProfileDialog(
Button(
modifier = modifier.weight(1f),
onClick = {
val builder = DeviceProfile.newBuilder()
deviceProfile.allFields.forEach { (field, value) ->
if (state[field] == true) {
builder.setField(field, value)
}
}
onConfirm(builder.build())
val result =
DeviceProfile(
long_name =
if (state[ProfileField.LONG_NAME] == true) deviceProfile.long_name else null,
short_name =
if (state[ProfileField.SHORT_NAME] == true) deviceProfile.short_name else null,
channel_url =
if (state[ProfileField.CHANNEL_URL] == true) deviceProfile.channel_url else null,
config = if (state[ProfileField.CONFIG] == true) deviceProfile.config else null,
module_config =
if (state[ProfileField.MODULE_CONFIG] == true) {
deviceProfile.module_config
} else {
null
},
fixed_position =
if (state[ProfileField.FIXED_POSITION] == true) {
deviceProfile.fixed_position
} else {
null
},
)
onConfirm(result)
},
enabled = state.values.any { it },
) {
@ -131,7 +173,7 @@ fun EditDeviceProfileDialog(
private fun EditDeviceProfileDialogPreview() {
EditDeviceProfileDialog(
title = "Export configuration",
deviceProfile = DeviceProfile.getDefaultInstance(),
deviceProfile = DeviceProfile(),
onConfirm = {},
onDismiss = {},
)

View file

@ -24,8 +24,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.FolderOpen
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@ -77,8 +77,7 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.util.IntervalConfiguration
import org.meshtastic.feature.settings.util.gpioPins
import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.copy
import org.meshtastic.proto.moduleConfig
import org.meshtastic.proto.ModuleConfig
import java.io.File
private const val MAX_RINGTONE_SIZE = 230
@ -91,7 +90,7 @@ fun ExternalNotificationConfigScreen(
viewModel: RadioConfigViewModel = hiltViewModel(),
) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val extNotificationConfig = state.moduleConfig.externalNotification
val extNotificationConfig = state.moduleConfig.external_notification ?: ModuleConfig.ExternalNotificationConfig()
val ringtone = state.ringtone
val formState = rememberConfigState(initialValue = extNotificationConfig)
var ringtoneInput by rememberSaveable(ringtone) { mutableStateOf(ringtone) }
@ -136,7 +135,7 @@ fun ExternalNotificationConfigScreen(
viewModel.setRingtone(ringtoneInput)
}
if (formState.value != extNotificationConfig) {
val config = moduleConfig { externalNotification = formState.value }
val config = ModuleConfig(external_notification = formState.value)
viewModel.setModuleConfig(config)
}
},
@ -145,9 +144,9 @@ fun ExternalNotificationConfigScreen(
TitledCard(title = stringResource(Res.string.external_notification_config)) {
SwitchPreference(
title = stringResource(Res.string.external_notification_enabled),
checked = formState.value.enabled,
checked = formState.value.enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } },
onCheckedChange = { formState.value = formState.value.copy(enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
@ -157,25 +156,25 @@ fun ExternalNotificationConfigScreen(
TitledCard(title = stringResource(Res.string.notifications_on_message_receipt)) {
SwitchPreference(
title = stringResource(Res.string.alert_message_led),
checked = formState.value.alertMessage,
checked = formState.value.alert_message ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { alertMessage = it } },
onCheckedChange = { formState.value = formState.value.copy(alert_message = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.alert_message_buzzer),
checked = formState.value.alertMessageBuzzer,
checked = formState.value.alert_message_buzzer ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { alertMessageBuzzer = it } },
onCheckedChange = { formState.value = formState.value.copy(alert_message_buzzer = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.alert_message_vibra),
checked = formState.value.alertMessageVibra,
checked = formState.value.alert_message_vibra ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { alertMessageVibra = it } },
onCheckedChange = { formState.value = formState.value.copy(alert_message_vibra = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
@ -185,25 +184,25 @@ fun ExternalNotificationConfigScreen(
TitledCard(title = stringResource(Res.string.notifications_on_alert_bell_receipt)) {
SwitchPreference(
title = stringResource(Res.string.alert_bell_led),
checked = formState.value.alertBell,
checked = formState.value.alert_bell ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { alertBell = it } },
onCheckedChange = { formState.value = formState.value.copy(alert_bell = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.alert_bell_buzzer),
checked = formState.value.alertBellBuzzer,
checked = formState.value.alert_bell_buzzer ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { alertBellBuzzer = it } },
onCheckedChange = { formState.value = formState.value.copy(alert_bell_buzzer = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.alert_bell_vibra),
checked = formState.value.alertBellVibra,
checked = formState.value.alert_bell_vibra ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { alertBellVibra = it } },
onCheckedChange = { formState.value = formState.value.copy(alert_bell_vibra = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
@ -215,17 +214,17 @@ fun ExternalNotificationConfigScreen(
DropDownPreference(
title = stringResource(Res.string.output_led_gpio),
items = gpio,
selectedItem = formState.value.output,
selectedItem = (formState.value.output ?: 0).toLong(),
enabled = state.connected,
onItemSelected = { formState.value = formState.value.copy { output = it } },
onItemSelected = { formState.value = formState.value.copy(output = it.toInt()) },
)
if (formState.value.output != 0) {
if (formState.value.output ?: 0 != 0) {
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.output_led_active_high),
checked = formState.value.active,
checked = formState.value.active ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { active = it } },
onCheckedChange = { formState.value = formState.value.copy(active = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
@ -233,17 +232,17 @@ fun ExternalNotificationConfigScreen(
DropDownPreference(
title = stringResource(Res.string.output_buzzer_gpio),
items = gpio,
selectedItem = formState.value.outputBuzzer,
selectedItem = (formState.value.output_buzzer ?: 0).toLong(),
enabled = state.connected,
onItemSelected = { formState.value = formState.value.copy { outputBuzzer = it } },
onItemSelected = { formState.value = formState.value.copy(output_buzzer = it.toInt()) },
)
if (formState.value.outputBuzzer != 0) {
if (formState.value.output_buzzer ?: 0 != 0) {
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.use_pwm_buzzer),
checked = formState.value.usePwm,
checked = formState.value.use_pwm ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { usePwm = it } },
onCheckedChange = { formState.value = formState.value.copy(use_pwm = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
@ -251,27 +250,27 @@ fun ExternalNotificationConfigScreen(
DropDownPreference(
title = stringResource(Res.string.output_vibra_gpio),
items = gpio,
selectedItem = formState.value.outputVibra,
selectedItem = (formState.value.output_vibra ?: 0).toLong(),
enabled = state.connected,
onItemSelected = { formState.value = formState.value.copy { outputVibra = it } },
onItemSelected = { formState.value = formState.value.copy(output_vibra = it.toInt()) },
)
HorizontalDivider()
val outputItems = remember { IntervalConfiguration.OUTPUT.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.output_duration_milliseconds),
items = outputItems.map { it.value to it.toDisplayString() },
selectedItem = formState.value.outputMs.toLong(),
selectedItem = (formState.value.output_ms ?: 0).toLong(),
enabled = state.connected,
onItemSelected = { formState.value = formState.value.copy { outputMs = it.toInt() } },
onItemSelected = { formState.value = formState.value.copy(output_ms = it.toInt()) },
)
HorizontalDivider()
val nagItems = remember { IntervalConfiguration.NAG_TIMEOUT.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.nag_timeout_seconds),
items = nagItems.map { it.value to it.toDisplayString() },
selectedItem = formState.value.nagTimeout.toLong(),
selectedItem = (formState.value.nag_timeout ?: 0).toLong(),
enabled = state.connected,
onItemSelected = { formState.value = formState.value.copy { nagTimeout = it.toInt() } },
onItemSelected = { formState.value = formState.value.copy(nag_timeout = it.toInt()) },
)
HorizontalDivider()
EditTextPreference(
@ -288,7 +287,7 @@ fun ExternalNotificationConfigScreen(
Row {
IconButton(onClick = { launcher.launch("*/*") }, enabled = state.connected) {
Icon(
Icons.Rounded.FolderOpen,
Icons.Default.FolderOpen,
contentDescription = stringResource(Res.string.import_label),
)
}
@ -312,7 +311,7 @@ fun ExternalNotificationConfigScreen(
},
enabled = state.connected,
) {
Icon(Icons.Rounded.PlayArrow, contentDescription = stringResource(Res.string.play))
Icon(Icons.Default.PlayArrow, contentDescription = stringResource(Res.string.play))
}
}
},
@ -320,9 +319,9 @@ fun ExternalNotificationConfigScreen(
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.use_i2s_as_buzzer),
checked = formState.value.useI2SAsBuzzer,
checked = formState.value.use_i2s_as_buzzer ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { useI2SAsBuzzer = it } },
onCheckedChange = { formState.value = formState.value.copy(use_i2s_as_buzzer = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}

View file

@ -60,16 +60,14 @@ import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.SignedIntegerEditTextPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.util.labelRes
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.util.hopLimits
import org.meshtastic.proto.config
import org.meshtastic.proto.copy
import org.meshtastic.proto.Config
@Composable
fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val loraConfig = state.radioConfig.lora
val loraConfig = state.radioConfig.lora ?: Config.LoRaConfig()
val primarySettings = state.channelList.getOrNull(0) ?: return
val formState = rememberConfigState(initialValue = loraConfig)
@ -84,7 +82,7 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = config { lora = it }
val config = Config(lora = it)
viewModel.setConfig(config)
},
) {
@ -96,49 +94,49 @@ 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 = { formState.value = formState.value.copy(region = it) },
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.use_modem_preset),
checked = formState.value.usePreset,
checked = formState.value.use_preset,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { usePreset = it } },
onCheckedChange = { formState.value = formState.value.copy(use_preset = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
if (formState.value.usePreset) {
if (formState.value.use_preset) {
DropDownPreference(
title = stringResource(Res.string.modem_preset),
summary = stringResource(Res.string.config_lora_modem_preset_summary),
enabled = state.connected && formState.value.usePreset,
items = ChannelOption.entries.map { it.modemPreset to stringResource(it.labelRes) },
selectedItem = formState.value.modemPreset,
onItemSelected = { formState.value = formState.value.copy { modemPreset = it } },
enabled = state.connected && formState.value.use_preset,
items = ChannelOption.entries.map { it.modemPreset to it.modemPreset.name },
selectedItem = formState.value.modem_preset,
onItemSelected = { formState.value = formState.value.copy(modem_preset = it) },
)
} else {
EditTextPreference(
title = stringResource(Res.string.bandwidth),
value = formState.value.bandwidth,
enabled = state.connected && !formState.value.usePreset,
enabled = state.connected && !formState.value.use_preset,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { bandwidth = it } },
onValueChanged = { formState.value = formState.value.copy(bandwidth = it) },
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.spread_factor),
value = formState.value.spreadFactor,
enabled = state.connected && !formState.value.usePreset,
value = formState.value.spread_factor,
enabled = state.connected && !formState.value.use_preset,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { spreadFactor = it } },
onValueChanged = { formState.value = formState.value.copy(spread_factor = it) },
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.coding_rate),
value = formState.value.codingRate,
enabled = state.connected && !formState.value.usePreset,
value = formState.value.coding_rate,
enabled = state.connected && !formState.value.use_preset,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { codingRate = it } },
onValueChanged = { formState.value = formState.value.copy(coding_rate = it) },
)
}
}
@ -148,33 +146,33 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
TitledCard(title = stringResource(Res.string.advanced)) {
SwitchPreference(
title = stringResource(Res.string.ignore_mqtt),
checked = formState.value.ignoreMqtt,
checked = formState.value.ignore_mqtt,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { ignoreMqtt = it } },
onCheckedChange = { formState.value = formState.value.copy(ignore_mqtt = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.ok_to_mqtt),
checked = formState.value.configOkToMqtt,
checked = formState.value.config_ok_to_mqtt,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { configOkToMqtt = it } },
onCheckedChange = { formState.value = formState.value.copy(config_ok_to_mqtt = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.tx_enabled),
checked = formState.value.txEnabled,
checked = formState.value.tx_enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { txEnabled = it } },
onCheckedChange = { formState.value = formState.value.copy(tx_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.override_duty_cycle),
checked = formState.value.overrideDutyCycle,
checked = formState.value.override_duty_cycle,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { overrideDutyCycle = it } },
onCheckedChange = { formState.value = formState.value.copy(override_duty_cycle = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
@ -183,8 +181,8 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
title = stringResource(Res.string.hop_limit),
summary = stringResource(Res.string.config_lora_hop_limit_summary),
items = hopLimitItems,
selectedItem = formState.value.hopLimit,
onItemSelected = { formState.value = formState.value.copy { hopLimit = it } },
selectedItem = formState.value.hop_limit.toLong(),
onItemSelected = { formState.value = formState.value.copy(hop_limit = it.toInt()) },
enabled = state.connected,
)
HorizontalDivider()
@ -193,8 +191,8 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
title = stringResource(Res.string.frequency_slot),
summary = stringResource(Res.string.config_lora_frequency_slot_summary),
value =
if (isFocusedSlot || formState.value.channelNum != 0) {
formState.value.channelNum
if (isFocusedSlot || formState.value.channel_num != 0) {
formState.value.channel_num
} else {
primaryChannel.channelNum
},
@ -203,16 +201,16 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
onFocusChanged = { isFocusedSlot = it.isFocused },
onValueChanged = {
if (it <= formState.value.numChannels) { // total num of LoRa channels
formState.value = formState.value.copy { channelNum = it }
formState.value = formState.value.copy(channel_num = it)
}
},
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.sx126x_rx_boosted_gain),
checked = formState.value.sx126XRxBoostedGain,
checked = formState.value.sx126x_rx_boosted_gain,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { sx126XRxBoostedGain = it } },
onCheckedChange = { formState.value = formState.value.copy(sx126x_rx_boosted_gain = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
@ -220,31 +218,31 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
EditTextPreference(
title = stringResource(Res.string.override_frequency_mhz),
value =
if (isFocusedOverride || formState.value.overrideFrequency != 0f) {
formState.value.overrideFrequency
if (isFocusedOverride || formState.value.override_frequency != 0f) {
formState.value.override_frequency
} else {
primaryChannel.radioFreq
},
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onFocusChanged = { isFocusedOverride = it.isFocused },
onValueChanged = { formState.value = formState.value.copy { overrideFrequency = it } },
onValueChanged = { formState.value = formState.value.copy(override_frequency = it) },
)
HorizontalDivider()
SignedIntegerEditTextPreference(
title = stringResource(Res.string.tx_power_dbm),
value = formState.value.txPower,
value = formState.value.tx_power,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { txPower = it } },
onValueChanged = { formState.value = formState.value.copy(tx_power = it) },
)
if (viewModel.hasPaFan) {
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.pa_fan_disabled),
checked = formState.value.paFanDisabled,
checked = formState.value.pa_fan_disabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { paFanDisabled = it } },
onCheckedChange = { formState.value = formState.value.copy(pa_fan_disabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
@file:Suppress("LongMethod")
package org.meshtastic.feature.settings.radio.component
@ -50,29 +49,26 @@ import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.copy
import org.meshtastic.proto.moduleConfig
import org.meshtastic.proto.ModuleConfig
@Composable
fun MQTTConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val destNode by viewModel.destNode.collectAsStateWithLifecycle()
val destNum = destNode?.num
val mqttConfig = state.moduleConfig.mqtt
val mqttConfig = state.moduleConfig.mqtt ?: ModuleConfig.MQTTConfig()
val formState = rememberConfigState(initialValue = mqttConfig)
if (!formState.value.mapReportSettings.shouldReportLocation) {
val settings =
formState.value.mapReportSettings.copy {
this.shouldReportLocation = viewModel.shouldReportLocation(destNum)
}
formState.value = formState.value.copy { mapReportSettings = settings }
val currentMapReportSettings = formState.value.map_report_settings ?: ModuleConfig.MapReportSettings()
if (!(currentMapReportSettings.should_report_location ?: false)) {
val settings = currentMapReportSettings.copy(should_report_location = viewModel.shouldReportLocation(destNum))
formState.value = formState.value.copy(map_report_settings = settings)
}
val consentValid =
if (formState.value.mapReportingEnabled) {
formState.value.mapReportSettings.shouldReportLocation &&
formState.value.mapReportSettings.publishIntervalSecs >= MIN_INTERVAL_SECS
if (formState.value.map_reporting_enabled ?: false) {
(formState.value.map_report_settings?.should_report_location ?: false) &&
(formState.value.map_report_settings?.publish_interval_secs ?: 0) >= MIN_INTERVAL_SECS
} else {
true
}
@ -86,7 +82,7 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack:
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { mqtt = it }
val config = ModuleConfig(mqtt = it)
viewModel.setModuleConfig(config)
},
) {
@ -94,89 +90,91 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack:
TitledCard(title = stringResource(Res.string.mqtt_config)) {
SwitchPreference(
title = stringResource(Res.string.mqtt_enabled),
checked = formState.value.enabled,
checked = formState.value.enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } },
onCheckedChange = { formState.value = formState.value.copy(enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.address),
value = formState.value.address,
value = formState.value.address ?: "",
maxSize = 63, // address max_size:64
enabled = state.connected,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { address = it } },
onValueChanged = { formState.value = formState.value.copy(address = it) },
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.username),
value = formState.value.username,
value = formState.value.username ?: "",
maxSize = 63, // username max_size:64
enabled = state.connected,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { username = it } },
onValueChanged = { formState.value = formState.value.copy(username = it) },
)
HorizontalDivider()
EditPasswordPreference(
title = stringResource(Res.string.password),
value = formState.value.password,
value = formState.value.password ?: "",
maxSize = 63, // password max_size:64
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { password = it } },
onValueChanged = { formState.value = formState.value.copy(password = it) },
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.encryption_enabled),
checked = formState.value.encryptionEnabled,
checked = formState.value.encryption_enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { encryptionEnabled = it } },
onCheckedChange = { formState.value = formState.value.copy(encryption_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.json_output_enabled),
checked = formState.value.jsonEnabled,
checked = formState.value.json_enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { jsonEnabled = it } },
onCheckedChange = { formState.value = formState.value.copy(json_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
val defaultAddress = stringResource(Res.string.default_mqtt_address)
val isDefault = formState.value.address.isEmpty() || formState.value.address.contains(defaultAddress)
val enforceTls = isDefault && formState.value.proxyToClientEnabled
val isDefault =
(formState.value.address ?: "").isEmpty() ||
(formState.value.address ?: "").contains(defaultAddress)
val enforceTls = isDefault && (formState.value.proxy_to_client_enabled ?: false)
SwitchPreference(
title = stringResource(Res.string.tls_enabled),
checked = formState.value.tlsEnabled || enforceTls,
checked = (formState.value.tls_enabled ?: false) || enforceTls,
enabled = state.connected && !enforceTls,
onCheckedChange = { formState.value = formState.value.copy { tlsEnabled = it } },
onCheckedChange = { formState.value = formState.value.copy(tls_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.root_topic),
value = formState.value.root,
value = formState.value.root ?: "",
maxSize = 31, // root max_size:32
enabled = state.connected,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { root = it } },
onValueChanged = { formState.value = formState.value.copy(root = it) },
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.proxy_to_client_enabled),
checked = formState.value.proxyToClientEnabled,
checked = formState.value.proxy_to_client_enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { proxyToClientEnabled = it } },
onCheckedChange = { formState.value = formState.value.copy(proxy_to_client_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
@ -184,26 +182,27 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack:
item {
TitledCard(title = stringResource(Res.string.map_reporting)) {
val mapReportSettings = formState.value.map_report_settings ?: ModuleConfig.MapReportSettings()
MapReportingPreference(
mapReportingEnabled = formState.value.mapReportingEnabled,
mapReportingEnabled = formState.value.map_reporting_enabled ?: false,
onMapReportingEnabledChanged = {
formState.value = formState.value.copy { mapReportingEnabled = it }
formState.value = formState.value.copy(map_reporting_enabled = it)
},
shouldReportLocation = formState.value.mapReportSettings.shouldReportLocation,
shouldReportLocation = mapReportSettings.should_report_location ?: false,
onShouldReportLocationChanged = {
viewModel.setShouldReportLocation(destNum, it)
val settings = formState.value.mapReportSettings.copy { this.shouldReportLocation = it }
formState.value = formState.value.copy { mapReportSettings = settings }
val settings = mapReportSettings.copy(should_report_location = it)
formState.value = formState.value.copy(map_report_settings = settings)
},
positionPrecision = formState.value.mapReportSettings.positionPrecision,
positionPrecision = mapReportSettings.position_precision ?: 0,
onPositionPrecisionChanged = {
val settings = formState.value.mapReportSettings.copy { positionPrecision = it }
formState.value = formState.value.copy { mapReportSettings = settings }
val settings = mapReportSettings.copy(position_precision = it)
formState.value = formState.value.copy(map_report_settings = settings)
},
publishIntervalSecs = formState.value.mapReportSettings.publishIntervalSecs,
publishIntervalSecs = mapReportSettings.publish_interval_secs ?: 0,
onPublishIntervalSecsChanged = {
val settings = formState.value.mapReportSettings.copy { publishIntervalSecs = it }
formState.value = formState.value.copy { mapReportSettings = settings }
val settings = mapReportSettings.copy(publish_interval_secs = it)
formState.value = formState.value.copy(map_report_settings = settings)
},
enabled = state.connected,
)

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.foundation.text.KeyboardActions
@ -37,13 +36,12 @@ import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.copy
import org.meshtastic.proto.moduleConfig
import org.meshtastic.proto.ModuleConfig
@Composable
fun NeighborInfoConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val neighborInfoConfig = state.moduleConfig.neighborInfo
val neighborInfoConfig = state.moduleConfig.neighbor_info ?: ModuleConfig.NeighborInfoConfig()
val formState = rememberConfigState(initialValue = neighborInfoConfig)
val focusManager = LocalFocusManager.current
@ -55,7 +53,7 @@ fun NeighborInfoConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(),
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { neighborInfo = it }
val config = ModuleConfig(neighbor_info = it)
viewModel.setModuleConfig(config)
},
) {
@ -63,26 +61,26 @@ fun NeighborInfoConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(),
TitledCard(title = stringResource(Res.string.neighbor_info_config)) {
SwitchPreference(
title = stringResource(Res.string.neighbor_info_enabled),
checked = formState.value.enabled,
checked = formState.value.enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } },
onCheckedChange = { formState.value = formState.value.copy(enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.update_interval_seconds),
value = formState.value.updateInterval,
value = formState.value.update_interval ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { updateInterval = it } },
onValueChanged = { formState.value = formState.value.copy(update_interval = it) },
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.transmit_over_lora),
summary = stringResource(Res.string.config_device_transmitOverLora_summary),
checked = formState.value.transmitOverLora,
checked = formState.value.transmit_over_lora ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { transmitOverLora = it } },
onCheckedChange = { formState.value = formState.value.copy(transmit_over_lora = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import androidx.activity.compose.rememberLauncherForActivityResult
@ -61,7 +60,6 @@ import org.meshtastic.core.strings.password
import org.meshtastic.core.strings.rsyslog_server
import org.meshtastic.core.strings.ssid
import org.meshtastic.core.strings.subnet
import org.meshtastic.core.strings.udp_config
import org.meshtastic.core.strings.udp_enabled
import org.meshtastic.core.strings.wifi_config
import org.meshtastic.core.strings.wifi_enabled
@ -77,9 +75,7 @@ import org.meshtastic.core.ui.component.SimpleAlertDialog
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.ConfigProtos.Config.NetworkConfig
import org.meshtastic.proto.config
import org.meshtastic.proto.copy
import org.meshtastic.proto.Config
@Composable
private fun ScanErrorDialog(onDismiss: () -> Unit = {}) =
@ -88,7 +84,7 @@ private fun ScanErrorDialog(onDismiss: () -> Unit = {}) =
@Composable
fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val networkConfig = state.radioConfig.network
val networkConfig = state.radioConfig.network ?: Config.NetworkConfig()
val formState = rememberConfigState(initialValue = networkConfig)
var showScanErrorDialog: Boolean by rememberSaveable { mutableStateOf(false) }
@ -101,11 +97,7 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
if (result.contents != null) {
val (ssid, psk) = extractWifiCredentials(result.contents)
if (ssid != null && psk != null) {
formState.value =
formState.value.copy {
wifiSsid = ssid
wifiPsk = psk
}
formState.value = formState.value.copy(wifi_ssid = ssid, wifi_psk = psk)
} else {
showScanErrorDialog = true
}
@ -132,32 +124,31 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = config { network = it }
val config = Config(network = it)
viewModel.setConfig(config)
},
) {
// Display device connection status
state.deviceConnectionStatus?.let { connectionStatus ->
if (
connectionStatus.wifi?.status?.isConnected == true ||
connectionStatus.ethernet?.status?.isConnected == true
) {
val ws = connectionStatus.wifi?.status
val es = connectionStatus.ethernet?.status
if (ws?.is_connected == true || es?.is_connected == true) {
item {
TitledCard(title = stringResource(Res.string.connection_status)) {
connectionStatus.wifi?.let { wifiStatus ->
if (wifiStatus.status.isConnected) {
ws?.let { wifiStatus ->
if (wifiStatus.is_connected) {
ListItem(
text = stringResource(Res.string.wifi_ip),
supportingText = formatIpAddress(wifiStatus.status.ipAddress),
supportingText = formatIpAddress(wifiStatus.ip_address ?: 0),
trailingIcon = null,
)
}
}
connectionStatus.ethernet?.let { ethernetStatus ->
if (ethernetStatus.status.isConnected) {
es?.let { ethernetStatus ->
if (ethernetStatus.is_connected) {
ListItem(
text = stringResource(Res.string.ethernet_ip),
supportingText = formatIpAddress(ethernetStatus.status.ipAddress),
supportingText = formatIpAddress(ethernetStatus.ip_address ?: 0),
trailingIcon = null,
)
}
@ -172,31 +163,31 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
SwitchPreference(
title = stringResource(Res.string.wifi_enabled),
summary = stringResource(Res.string.config_network_wifi_enabled_summary),
checked = formState.value.wifiEnabled,
checked = formState.value.wifi_enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { wifiEnabled = it } },
onCheckedChange = { formState.value = formState.value.copy(wifi_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.ssid),
value = formState.value.wifiSsid,
value = formState.value.wifi_ssid ?: "",
maxSize = 32, // wifi_ssid max_size:33
enabled = state.connected,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { wifiSsid = it } },
onValueChanged = { formState.value = formState.value.copy(wifi_ssid = it) },
)
HorizontalDivider()
EditPasswordPreference(
title = stringResource(Res.string.password),
value = formState.value.wifiPsk,
value = formState.value.wifi_psk ?: "",
maxSize = 64, // wifi_psk max_size:65
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { wifiPsk = it } },
onValueChanged = { formState.value = formState.value.copy(wifi_psk = it) },
)
HorizontalDivider()
Button(
@ -215,9 +206,9 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
SwitchPreference(
title = stringResource(Res.string.ethernet_enabled),
summary = stringResource(Res.string.config_network_eth_enabled_summary),
checked = formState.value.ethEnabled,
checked = formState.value.eth_enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { ethEnabled = it } },
onCheckedChange = { formState.value = formState.value.copy(eth_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
@ -226,15 +217,14 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
if (state.metadata?.hasEthernet == true || state.metadata?.hasWifi == true) {
item {
TitledCard(title = stringResource(Res.string.udp_config)) {
TitledCard(title = stringResource(Res.string.network)) {
SwitchPreference(
title = stringResource(Res.string.udp_enabled),
summary = stringResource(Res.string.config_network_udp_enabled_summary),
checked = formState.value.enabledProtocols == 1,
checked = (formState.value.enabled_protocols ?: 0) == 1,
enabled = state.connected,
onCheckedChange = {
formState.value =
formState.value.copy { if (it) enabledProtocols = 1 else enabledProtocols = 0 }
formState.value = formState.value.copy(enabled_protocols = if (it) 1 else 0)
},
containerColor = CardDefaults.cardColors().containerColor,
)
@ -246,81 +236,71 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBac
TitledCard(title = stringResource(Res.string.advanced)) {
EditTextPreference(
title = stringResource(Res.string.ntp_server),
value = formState.value.ntpServer,
value = formState.value.ntp_server ?: "",
maxSize = 32, // ntp_server max_size:33
enabled = state.connected,
isError = formState.value.ntpServer.isEmpty(),
isError = formState.value.ntp_server?.isEmpty() ?: true,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { ntpServer = it } },
onValueChanged = { formState.value = formState.value.copy(ntp_server = it) },
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.rsyslog_server),
value = formState.value.rsyslogServer,
value = formState.value.rsyslog_server ?: "",
maxSize = 32, // rsyslog_server max_size:33
enabled = state.connected,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { rsyslogServer = it } },
onValueChanged = { formState.value = formState.value.copy(rsyslog_server = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.ipv4_mode),
enabled = state.connected,
items =
NetworkConfig.AddressMode.entries
.filter { it != NetworkConfig.AddressMode.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.addressMode,
onItemSelected = { formState.value = formState.value.copy { addressMode = it } },
items = Config.NetworkConfig.AddressMode.entries.map { it to it.name },
selectedItem = formState.value.address_mode ?: Config.NetworkConfig.AddressMode.DHCP,
onItemSelected = { formState.value = formState.value.copy(address_mode = it) },
)
HorizontalDivider()
val ipv4 = formState.value.ipv4_config ?: Config.NetworkConfig.IpV4Config()
EditIPv4Preference(
title = stringResource(Res.string.ip),
value = formState.value.ipv4Config.ip,
enabled = state.connected && formState.value.addressMode == NetworkConfig.AddressMode.STATIC,
value = ipv4.ip ?: 0,
enabled =
state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
val ipv4 = formState.value.ipv4Config.copy { ip = it }
formState.value = formState.value.copy { ipv4Config = ipv4 }
},
onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(ip = it)) },
)
HorizontalDivider()
EditIPv4Preference(
title = stringResource(Res.string.gateway),
value = formState.value.ipv4Config.gateway,
enabled = state.connected && formState.value.addressMode == NetworkConfig.AddressMode.STATIC,
value = ipv4.gateway ?: 0,
enabled =
state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
val ipv4 = formState.value.ipv4Config.copy { gateway = it }
formState.value = formState.value.copy { ipv4Config = ipv4 }
},
onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(gateway = it)) },
)
HorizontalDivider()
EditIPv4Preference(
title = stringResource(Res.string.subnet),
value = formState.value.ipv4Config.subnet,
enabled = state.connected && formState.value.addressMode == NetworkConfig.AddressMode.STATIC,
value = ipv4.subnet ?: 0,
enabled =
state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
val ipv4 = formState.value.ipv4Config.copy { subnet = it }
formState.value = formState.value.copy { ipv4Config = ipv4 }
},
onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(subnet = it)) },
)
HorizontalDivider()
EditIPv4Preference(
title = "DNS",
value = formState.value.ipv4Config.dns,
enabled = state.connected && formState.value.addressMode == NetworkConfig.AddressMode.STATIC,
value = ipv4.dns ?: 0,
enabled =
state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
val ipv4 = formState.value.ipv4Config.copy { dns = it }
formState.value = formState.value.copy { ipv4Config = ipv4 }
},
onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(dns = it)) },
)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.foundation.text.KeyboardActions
@ -41,13 +40,12 @@ import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.util.IntervalConfiguration
import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.copy
import org.meshtastic.proto.moduleConfig
import org.meshtastic.proto.ModuleConfig
@Composable
fun PaxcounterConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val paxcounterConfig = state.moduleConfig.paxcounter
val paxcounterConfig = state.moduleConfig.paxcounter ?: ModuleConfig.PaxcounterConfig()
val formState = rememberConfigState(initialValue = paxcounterConfig)
val focusManager = LocalFocusManager.current
@ -59,7 +57,7 @@ fun PaxcounterConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), on
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { paxcounter = it }
val config = ModuleConfig(paxcounter = it)
viewModel.setModuleConfig(config)
},
) {
@ -67,37 +65,37 @@ fun PaxcounterConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), on
TitledCard(title = stringResource(Res.string.paxcounter_config)) {
SwitchPreference(
title = stringResource(Res.string.paxcounter_enabled),
checked = formState.value.enabled,
checked = formState.value.enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } },
onCheckedChange = { formState.value = formState.value.copy(enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
val items = remember { IntervalConfiguration.PAX_COUNTER.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.update_interval_seconds),
selectedItem = formState.value.paxcounterUpdateInterval.toLong(),
selectedItem = (formState.value.paxcounter_update_interval ?: 0).toLong(),
enabled = state.connected,
items = items.map { it.value to it.toDisplayString() },
onItemSelected = {
formState.value = formState.value.copy { paxcounterUpdateInterval = it.toInt() }
formState.value = formState.value.copy(paxcounter_update_interval = it.toInt())
},
)
HorizontalDivider()
SignedIntegerEditTextPreference(
title = stringResource(Res.string.wifi_rssi_threshold_defaults_to_80),
value = formState.value.wifiThreshold,
value = formState.value.wifi_threshold ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { wifiThreshold = it } },
onValueChanged = { formState.value = formState.value.copy(wifi_threshold = it) },
)
HorizontalDivider()
SignedIntegerEditTextPreference(
title = stringResource(Res.string.ble_rssi_threshold_defaults_to_80),
value = formState.value.bleThreshold,
value = formState.value.ble_threshold ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { bleThreshold = it } },
onValueChanged = { formState.value = formState.value.copy(ble_threshold = it) },
)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import android.Manifest
@ -78,9 +77,7 @@ import org.meshtastic.feature.settings.util.FixedUpdateIntervals
import org.meshtastic.feature.settings.util.IntervalConfiguration
import org.meshtastic.feature.settings.util.gpioPins
import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.ConfigProtos.Config.PositionConfig
import org.meshtastic.proto.config
import org.meshtastic.proto.copy
import org.meshtastic.proto.Config
@OptIn(ExperimentalPermissionsApi::class)
@Composable
@ -96,22 +93,23 @@ fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
altitude = node?.position?.altitude ?: 0,
time = 1, // ignore time for fixed_position
)
val positionConfig = state.radioConfig.position
val positionConfig = state.radioConfig.position ?: Config.PositionConfig()
val sanitizedPositionConfig =
remember(positionConfig) {
val positionItems = IntervalConfiguration.POSITION.allowedIntervals
val smartBroadcastItems = IntervalConfiguration.SMART_BROADCAST_MINIMUM.allowedIntervals
positionConfig.copy {
if (FixedUpdateIntervals.fromValue(positionBroadcastSecs.toLong()) == null) {
positionBroadcastSecs = positionItems.first().value.toInt()
}
if (FixedUpdateIntervals.fromValue(broadcastSmartMinimumIntervalSecs.toLong()) == null) {
broadcastSmartMinimumIntervalSecs = smartBroadcastItems.first().value.toInt()
}
if (FixedUpdateIntervals.fromValue(gpsUpdateInterval.toLong()) == null) {
gpsUpdateInterval = positionItems.first().value.toInt()
}
var updated = positionConfig
if (FixedUpdateIntervals.fromValue((updated.position_broadcast_secs ?: 0).toLong()) == null) {
updated = updated.copy(position_broadcast_secs = positionItems.first().value.toInt())
}
if (FixedUpdateIntervals.fromValue((updated.broadcast_smart_minimum_interval_secs ?: 0).toLong()) == null) {
updated =
updated.copy(broadcast_smart_minimum_interval_secs = smartBroadcastItems.first().value.toInt())
}
if (FixedUpdateIntervals.fromValue((updated.gps_update_interval ?: 0).toLong()) == null) {
updated = updated.copy(gps_update_interval = positionItems.first().value.toInt())
}
updated
}
val formState = rememberConfigState(initialValue = sanitizedPositionConfig)
var locationInput by rememberSaveable { mutableStateOf(currentPosition) }
@ -152,17 +150,17 @@ fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
additionalDirtyCheck = { locationInput != currentPosition },
onDiscard = { locationInput = currentPosition },
onSave = {
if (formState.value.fixedPosition) {
if (formState.value.fixed_position) {
if (locationInput != currentPosition) {
viewModel.setFixedPosition(locationInput)
}
} else {
if (positionConfig.fixedPosition) {
if (positionConfig.fixed_position) {
// fixed position changed from enabled to disabled
viewModel.removeFixedPosition()
}
}
val config = config { position = it }
val config = Config(position = it)
viewModel.setConfig(config)
},
) {
@ -175,20 +173,21 @@ fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
enabled = state.connected,
items = items.map { it to it.toDisplayString() },
selectedItem =
FixedUpdateIntervals.fromValue(formState.value.positionBroadcastSecs.toLong()) ?: items.first(),
FixedUpdateIntervals.fromValue((formState.value.position_broadcast_secs ?: 0).toLong())
?: items.first(),
onItemSelected = {
formState.value = formState.value.copy { positionBroadcastSecs = it.value.toInt() }
formState.value = formState.value.copy(position_broadcast_secs = it.value.toInt())
},
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.smart_position),
checked = formState.value.positionBroadcastSmartEnabled,
checked = formState.value.position_broadcast_smart_enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { positionBroadcastSmartEnabled = it } },
onCheckedChange = { formState.value = formState.value.copy(position_broadcast_smart_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
if (formState.value.positionBroadcastSmartEnabled) {
if (formState.value.position_broadcast_smart_enabled ?: false) {
HorizontalDivider()
val smartItems = remember { IntervalConfiguration.SMART_BROADCAST_MINIMUM.allowedIntervals }
DropDownPreference(
@ -198,22 +197,23 @@ fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
enabled = state.connected,
items = smartItems.map { it to it.toDisplayString() },
selectedItem =
FixedUpdateIntervals.fromValue(formState.value.broadcastSmartMinimumIntervalSecs.toLong())
?: smartItems.first(),
FixedUpdateIntervals.fromValue(
(formState.value.broadcast_smart_minimum_interval_secs ?: 0).toLong(),
) ?: smartItems.first(),
onItemSelected = {
formState.value =
formState.value.copy { broadcastSmartMinimumIntervalSecs = it.value.toInt() }
formState.value.copy(broadcast_smart_minimum_interval_secs = it.value.toInt())
},
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.minimum_distance),
summary = stringResource(Res.string.config_position_broadcast_smart_minimum_distance_summary),
value = formState.value.broadcastSmartMinimumDistance,
value = formState.value.broadcast_smart_minimum_distance ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
formState.value = formState.value.copy { broadcastSmartMinimumDistance = it }
formState.value = formState.value.copy(broadcast_smart_minimum_distance = it)
},
)
}
@ -223,12 +223,12 @@ fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
TitledCard(title = stringResource(Res.string.device_gps)) {
SwitchPreference(
title = stringResource(Res.string.fixed_position),
checked = formState.value.fixedPosition,
checked = formState.value.fixed_position ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { fixedPosition = it } },
onCheckedChange = { formState.value = formState.value.copy(fixed_position = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
if (formState.value.fixedPosition) {
if (formState.value.fixed_position ?: false) {
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.latitude),
@ -273,12 +273,9 @@ fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
DropDownPreference(
title = stringResource(Res.string.gps_mode),
enabled = state.connected,
items =
PositionConfig.GpsMode.entries
.filter { it != PositionConfig.GpsMode.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.gpsMode,
onItemSelected = { formState.value = formState.value.copy { gpsMode = it } },
items = Config.PositionConfig.GpsMode.entries.map { it to it.name },
selectedItem = formState.value.gps_mode ?: Config.PositionConfig.GpsMode.DISABLED,
onItemSelected = { formState.value = formState.value.copy(gps_mode = it) },
)
HorizontalDivider()
val items = remember { IntervalConfiguration.GPS_UPDATE.allowedIntervals }
@ -288,9 +285,10 @@ fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
enabled = state.connected,
items = items.map { it to it.toDisplayString() },
selectedItem =
FixedUpdateIntervals.fromValue(formState.value.gpsUpdateInterval.toLong()) ?: items.first(),
FixedUpdateIntervals.fromValue((formState.value.gps_update_interval ?: 0).toLong())
?: items.first(),
onItemSelected = {
formState.value = formState.value.copy { gpsUpdateInterval = it.value.toInt() }
formState.value = formState.value.copy(gps_update_interval = it.value.toInt())
},
)
}
@ -301,16 +299,13 @@ fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
BitwisePreference(
title = stringResource(Res.string.position_flags),
summary = stringResource(Res.string.config_position_flags_summary),
value = formState.value.positionFlags,
value = formState.value.position_flags ?: 0,
enabled = state.connected,
items =
PositionConfig.PositionFlags.entries
.filter {
it != PositionConfig.PositionFlags.UNSET &&
it != PositionConfig.PositionFlags.UNRECOGNIZED
}
.map { it.number to it.name },
onItemSelected = { formState.value = formState.value.copy { positionFlags = it } },
Config.PositionConfig.PositionFlags.entries
.filter { it != Config.PositionConfig.PositionFlags.UNSET }
.map { it.value to it.name },
onItemSelected = { formState.value = formState.value.copy(position_flags = it) },
)
}
}
@ -321,24 +316,24 @@ fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
title = stringResource(Res.string.gps_receive_gpio),
enabled = state.connected,
items = pins,
selectedItem = formState.value.rxGpio,
onItemSelected = { formState.value = formState.value.copy { rxGpio = it } },
selectedItem = formState.value.rx_gpio ?: 0,
onItemSelected = { formState.value = formState.value.copy(rx_gpio = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.gps_transmit_gpio),
enabled = state.connected,
items = pins,
selectedItem = formState.value.txGpio,
onItemSelected = { formState.value = formState.value.copy { txGpio = it } },
selectedItem = formState.value.tx_gpio ?: 0,
onItemSelected = { formState.value = formState.value.copy(tx_gpio = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.gps_en_gpio),
enabled = state.connected,
items = pins,
selectedItem = formState.value.gpsEnGpio,
onItemSelected = { formState.value = formState.value.copy { gpsEnGpio = it } },
selectedItem = formState.value.gps_en_gpio ?: 0,
onItemSelected = { formState.value = formState.value.copy(gps_en_gpio = it) },
)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.foundation.text.KeyboardActions
@ -46,13 +45,12 @@ import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.util.IntervalConfiguration
import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.config
import org.meshtastic.proto.copy
import org.meshtastic.proto.Config
@Composable
fun PowerConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val powerConfig = state.radioConfig.power
val powerConfig = state.radioConfig.power ?: Config.PowerConfig()
val formState = rememberConfigState(initialValue = powerConfig)
val focusManager = LocalFocusManager.current
@ -64,7 +62,7 @@ fun PowerConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack:
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = config { power = it }
val config = Config(power = it)
viewModel.setConfig(config)
},
) {
@ -73,57 +71,57 @@ fun PowerConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack:
SwitchPreference(
title = stringResource(Res.string.enable_power_saving_mode),
summary = stringResource(Res.string.config_power_is_power_saving_summary),
checked = formState.value.isPowerSaving,
checked = formState.value.is_power_saving ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { isPowerSaving = it } },
onCheckedChange = { formState.value = formState.value.copy(is_power_saving = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
val items = remember { IntervalConfiguration.ALL.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.shutdown_on_power_loss),
selectedItem = formState.value.onBatteryShutdownAfterSecs.toLong(),
selectedItem = (formState.value.on_battery_shutdown_after_secs ?: 0).toLong(),
enabled = state.connected,
items = items.map { it.value to it.toDisplayString() },
onItemSelected = {
formState.value = formState.value.copy { onBatteryShutdownAfterSecs = it.toInt() }
formState.value = formState.value.copy(on_battery_shutdown_after_secs = it.toInt())
},
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.adc_multiplier_override),
checked = formState.value.adcMultiplierOverride > 0f,
checked = (formState.value.adc_multiplier_override ?: 0f) > 0f,
enabled = state.connected,
onCheckedChange = {
formState.value = formState.value.copy { adcMultiplierOverride = if (it) 1.0f else 0.0f }
formState.value = formState.value.copy(adc_multiplier_override = if (it) 1.0f else 0.0f)
},
containerColor = CardDefaults.cardColors().containerColor,
)
if (formState.value.adcMultiplierOverride > 0f) {
if ((formState.value.adc_multiplier_override ?: 0f) > 0f) {
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.adc_multiplier_override_ratio),
value = formState.value.adcMultiplierOverride,
value = formState.value.adc_multiplier_override ?: 0f,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { adcMultiplierOverride = it } },
onValueChanged = { formState.value = formState.value.copy(adc_multiplier_override = it) },
)
}
HorizontalDivider()
val waitBluetoothItems = remember { IntervalConfiguration.NAG_TIMEOUT.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.wait_for_bluetooth_duration_seconds),
selectedItem = formState.value.waitBluetoothSecs.toLong(),
selectedItem = (formState.value.wait_bluetooth_secs ?: 0).toLong(),
enabled = state.connected,
items = waitBluetoothItems.map { it.value to it.toDisplayString() },
onItemSelected = { formState.value = formState.value.copy { waitBluetoothSecs = it.toInt() } },
onItemSelected = { formState.value = formState.value.copy(wait_bluetooth_secs = it.toInt()) },
)
HorizontalDivider()
val sdsSecsItems = remember { IntervalConfiguration.ALL.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.super_deep_sleep_duration_seconds),
selectedItem = formState.value.sdsSecs.toLong(),
onItemSelected = { formState.value = formState.value.copy { sdsSecs = it.toInt() } },
selectedItem = (formState.value.sds_secs ?: 0).toLong(),
onItemSelected = { formState.value = formState.value.copy(sds_secs = it.toInt()) },
enabled = state.connected,
items = sdsSecsItems.map { it.value to it.toDisplayString() },
)
@ -131,18 +129,18 @@ fun PowerConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack:
val minWakeItems = remember { IntervalConfiguration.NAG_TIMEOUT.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.minimum_wake_time_seconds),
selectedItem = formState.value.minWakeSecs.toLong(),
selectedItem = (formState.value.min_wake_secs ?: 0).toLong(),
enabled = state.connected,
items = minWakeItems.map { it.value to it.toDisplayString() },
onItemSelected = { formState.value = formState.value.copy { minWakeSecs = it.toInt() } },
onItemSelected = { formState.value = formState.value.copy(min_wake_secs = it.toInt()) },
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.battery_ina_2xx_i2c_address),
value = formState.value.deviceBatteryInaAddress,
value = formState.value.device_battery_ina_address ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { deviceBatteryInaAddress = it } },
onValueChanged = { formState.value = formState.value.copy(device_battery_ina_address = it) },
)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.animation.AnimatedVisibility
@ -33,7 +32,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.unit.dp
import com.google.protobuf.MessageLite
import com.squareup.wire.Message
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.discard_changes
@ -44,7 +43,7 @@ import org.meshtastic.feature.settings.radio.ResponseState
@Suppress("LongMethod")
@Composable
fun <T : MessageLite> RadioConfigScreenList(
fun <T : Message<T, *>> RadioConfigScreenList(
title: String,
onBack: () -> Unit,
responseState: ResponseState<Any>,

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.material3.CardDefaults
@ -37,13 +36,12 @@ import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.util.IntervalConfiguration
import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.copy
import org.meshtastic.proto.moduleConfig
import org.meshtastic.proto.ModuleConfig
@Composable
fun RangeTestConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val rangeTestConfig = state.moduleConfig.rangeTest
val rangeTestConfig = state.moduleConfig.range_test ?: ModuleConfig.RangeTestConfig()
val formState = rememberConfigState(initialValue = rangeTestConfig)
RadioConfigScreenList(
@ -54,7 +52,7 @@ fun RangeTestConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onB
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { rangeTest = it }
val config = ModuleConfig(range_test = it)
viewModel.setModuleConfig(config)
},
) {
@ -62,26 +60,26 @@ fun RangeTestConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onB
TitledCard(title = stringResource(Res.string.range_test_config)) {
SwitchPreference(
title = stringResource(Res.string.range_test_enabled),
checked = formState.value.enabled,
checked = formState.value.enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } },
onCheckedChange = { formState.value = formState.value.copy(enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
val rangeItems = remember { IntervalConfiguration.RANGE_TEST_SENDER.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.sender_message_interval_seconds),
selectedItem = formState.value.sender.toLong(),
selectedItem = (formState.value.sender ?: 0).toLong(),
enabled = state.connected,
items = rangeItems.map { it.value to it.toDisplayString() },
onItemSelected = { formState.value = formState.value.copy { sender = it.toInt() } },
onItemSelected = { formState.value = formState.value.copy(sender = it.toInt()) },
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.save_csv_in_storage_esp32_only),
checked = formState.value.save,
checked = formState.value.save ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { save = it } },
onCheckedChange = { formState.value = formState.value.copy(save = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.foundation.text.KeyboardActions
@ -36,13 +35,12 @@ import org.meshtastic.core.ui.component.EditListPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.copy
import org.meshtastic.proto.moduleConfig
import org.meshtastic.proto.ModuleConfig
@Composable
fun RemoteHardwareConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val remoteHardwareConfig = state.moduleConfig.remoteHardware
val remoteHardwareConfig = state.moduleConfig.remote_hardware ?: ModuleConfig.RemoteHardwareConfig()
val formState = rememberConfigState(initialValue = remoteHardwareConfig)
val focusManager = LocalFocusManager.current
@ -54,7 +52,7 @@ fun RemoteHardwareConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel()
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { remoteHardware = it }
val config = ModuleConfig(remote_hardware = it)
viewModel.setModuleConfig(config)
},
) {
@ -62,33 +60,27 @@ fun RemoteHardwareConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel()
TitledCard(title = stringResource(Res.string.remote_hardware_config)) {
SwitchPreference(
title = stringResource(Res.string.remote_hardware_enabled),
checked = formState.value.enabled,
checked = formState.value.enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } },
onCheckedChange = { formState.value = formState.value.copy(enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.allow_undefined_pin_access),
checked = formState.value.allowUndefinedPinAccess,
checked = formState.value.allow_undefined_pin_access ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { allowUndefinedPinAccess = it } },
onCheckedChange = { formState.value = formState.value.copy(allow_undefined_pin_access = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
EditListPreference(
title = stringResource(Res.string.available_pins),
list = formState.value.availablePinsList,
list = formState.value.available_pins,
maxCount = 4, // available_pins max_count:4
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValuesChanged = { list ->
formState.value =
formState.value.copy {
availablePins.clear()
availablePins.addAll(list)
}
},
onValuesChanged = { list -> formState.value = formState.value.copy(available_pins = list) },
)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import android.app.Activity
@ -42,10 +41,10 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.protobuf.ByteString
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.encodeToString
import org.meshtastic.core.model.util.toByteString
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.admin_key
import org.meshtastic.core.strings.admin_keys
@ -77,9 +76,7 @@ import org.meshtastic.core.ui.component.EditListPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.ConfigProtos.Config.SecurityConfig
import org.meshtastic.proto.config
import org.meshtastic.proto.copy
import org.meshtastic.proto.Config
import java.security.SecureRandom
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@ -87,15 +84,15 @@ import java.security.SecureRandom
fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val node by viewModel.destNode.collectAsStateWithLifecycle()
val securityConfig = state.radioConfig.security
val securityConfig = state.radioConfig.security ?: Config.SecurityConfig()
val formState = rememberConfigState(initialValue = securityConfig)
var publicKey by rememberSaveable { mutableStateOf(formState.value.publicKey) }
LaunchedEffect(formState.value.privateKey) {
if (formState.value.privateKey != securityConfig.privateKey) {
publicKey = "".toByteString()
} else if (formState.value.privateKey == securityConfig.privateKey) {
publicKey = securityConfig.publicKey
var publicKey by rememberSaveable { mutableStateOf(formState.value.public_key) }
LaunchedEffect(formState.value.private_key) {
if (formState.value.private_key != securityConfig.private_key) {
publicKey = ByteString.EMPTY
} else if (formState.value.private_key == securityConfig.private_key) {
publicKey = securityConfig.public_key
}
}
@ -112,7 +109,7 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
onConfirm = {
formState.value = it
showKeyGenerationDialog = false
val config = config { security = formState.value }
val config = Config(security = formState.value)
viewModel.setConfig(config)
},
onDismiss = { showKeyGenerationDialog = false },
@ -133,7 +130,7 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
type = "application/*"
putExtra(
Intent.EXTRA_TITLE,
"${node?.user?.shortName}_keys_${System.currentTimeMillis()}.json",
"${node?.user?.short_name}_keys_${System.currentTimeMillis()}.json",
)
}
exportConfigLauncher.launch(intent)
@ -154,7 +151,7 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = config { security = it }
val config = Config(security = it)
viewModel.setConfig(config)
},
) {
@ -168,25 +165,25 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
readOnly = true,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChange = {
if (it.size() == 32) {
formState.value = formState.value.copy { this.publicKey = it }
if (it.size == 32) {
formState.value = formState.value.copy(public_key = it)
}
},
trailingIcon = { CopyIconButton(valueToCopy = formState.value.publicKey.encodeToString()) },
trailingIcon = { CopyIconButton(valueToCopy = formState.value.public_key.encodeToString()) },
)
HorizontalDivider()
EditBase64Preference(
title = stringResource(Res.string.private_key),
summary = stringResource(Res.string.config_security_private_key),
value = formState.value.privateKey,
value = formState.value.private_key,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChange = {
if (it.size() == 32) {
formState.value = formState.value.copy { privateKey = it }
if (it.size == 32) {
formState.value = formState.value.copy(private_key = it)
}
},
trailingIcon = { CopyIconButton(valueToCopy = formState.value.privateKey.encodeToString()) },
trailingIcon = { CopyIconButton(valueToCopy = formState.value.private_key.encodeToString()) },
)
HorizontalDivider()
NodeActionButton(
@ -211,17 +208,11 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
EditListPreference(
title = stringResource(Res.string.admin_key),
summary = stringResource(Res.string.config_security_admin_key),
list = formState.value.adminKeyList,
list = formState.value.admin_key,
maxCount = 3,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValuesChanged = {
formState.value =
formState.value.copy {
adminKey.clear()
adminKey.addAll(it)
}
},
onValuesChanged = { formState.value = formState.value.copy(admin_key = it) },
)
}
}
@ -230,18 +221,18 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
SwitchPreference(
title = stringResource(Res.string.serial_console),
summary = stringResource(Res.string.config_security_serial_enabled),
checked = formState.value.serialEnabled,
checked = formState.value.serial_enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { serialEnabled = it } },
onCheckedChange = { formState.value = formState.value.copy(serial_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.debug_log_api_enabled),
summary = stringResource(Res.string.config_security_debug_log_api_enabled),
checked = formState.value.debugLogApiEnabled,
checked = formState.value.debug_log_api_enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { debugLogApiEnabled = it } },
onCheckedChange = { formState.value = formState.value.copy(debug_log_api_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
@ -251,17 +242,17 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
SwitchPreference(
title = stringResource(Res.string.managed_mode),
summary = stringResource(Res.string.config_security_is_managed),
checked = formState.value.isManaged,
enabled = state.connected && formState.value.adminKeyCount > 0,
onCheckedChange = { formState.value = formState.value.copy { isManaged = it } },
checked = formState.value.is_managed,
enabled = state.connected && formState.value.admin_key.isNotEmpty(),
onCheckedChange = { formState.value = formState.value.copy(is_managed = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.legacy_admin_channel),
checked = formState.value.adminChannelEnabled,
checked = formState.value.admin_channel_enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { adminChannelEnabled = it } },
onCheckedChange = { formState.value = formState.value.copy(admin_channel_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
@ -273,7 +264,7 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
@Composable
fun PrivateKeyRegenerateDialog(
showKeyGenerationDialog: Boolean,
onConfirm: (SecurityConfig) -> Unit,
onConfirm: (Config.SecurityConfig) -> Unit,
onDismiss: () -> Unit = {},
) {
if (showKeyGenerationDialog) {
@ -284,22 +275,16 @@ fun PrivateKeyRegenerateDialog(
confirmButton = {
TextButton(
onClick = {
// Generate a random "f" value
val f = ByteArray(32).apply { SecureRandom().nextBytes(this) }
// Adjust the value to make it valid as an "s" value for eval().
// According to the specification we need to mask off the 3
// right-most bits of f[0], mask off the left-most bit of f[31],
// and set the second to left-most bit of f[31].
f[0] = (f[0].toInt() and 0xF8).toByte()
f[31] = ((f[31].toInt() and 0x7F) or 0x40).toByte()
val securityInput =
SecurityConfig.newBuilder()
.apply {
clearPrivateKey()
clearPublicKey()
// Generate a random "f" value
val f = ByteArray(32).apply { SecureRandom().nextBytes(this) }
// Adjust the value to make it valid as an "s" value for eval().
// According to the specification we need to mask off the 3
// right-most bits of f[0], mask off the left-most bit of f[31],
// and set the second to left-most bit of f[31].
f[0] = (f[0].toInt() and 0xF8).toByte()
f[31] = ((f[31].toInt() and 0x7F) or 0x40).toByte()
privateKey = ByteString.copyFrom(f)
}
.build()
Config.SecurityConfig(private_key = f.toByteString(), public_key = ByteString.EMPTY)
onConfirm(securityInput)
},
) {

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.foundation.text.KeyboardActions
@ -40,14 +39,12 @@ import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.ModuleConfigProtos.ModuleConfig.SerialConfig
import org.meshtastic.proto.copy
import org.meshtastic.proto.moduleConfig
import org.meshtastic.proto.ModuleConfig
@Composable
fun SerialConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val serialConfig = state.moduleConfig.serial
val serialConfig = state.moduleConfig.serial ?: ModuleConfig.SerialConfig()
val formState = rememberConfigState(initialValue = serialConfig)
val focusManager = LocalFocusManager.current
@ -59,7 +56,7 @@ fun SerialConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { serial = it }
val config = ModuleConfig(serial = it)
viewModel.setModuleConfig(config)
},
) {
@ -67,71 +64,65 @@ fun SerialConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack
TitledCard(title = stringResource(Res.string.serial_config)) {
SwitchPreference(
title = stringResource(Res.string.serial_enabled),
checked = formState.value.enabled,
checked = formState.value.enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } },
onCheckedChange = { formState.value = formState.value.copy(enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.echo_enabled),
checked = formState.value.echo,
checked = formState.value.echo ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { echo = it } },
onCheckedChange = { formState.value = formState.value.copy(echo = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
EditTextPreference(
title = "RX",
value = formState.value.rxd,
value = formState.value.rxd ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { rxd = it } },
onValueChanged = { formState.value = formState.value.copy(rxd = it) },
)
HorizontalDivider()
EditTextPreference(
title = "TX",
value = formState.value.txd,
value = formState.value.txd ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { txd = it } },
onValueChanged = { formState.value = formState.value.copy(txd = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.serial_baud_rate),
enabled = state.connected,
items =
SerialConfig.Serial_Baud.entries
.filter { it != SerialConfig.Serial_Baud.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.baud,
onItemSelected = { formState.value = formState.value.copy { baud = it } },
items = ModuleConfig.SerialConfig.Serial_Baud.entries.map { it to it.name },
selectedItem = formState.value.baud ?: ModuleConfig.SerialConfig.Serial_Baud.BAUD_DEFAULT,
onItemSelected = { formState.value = formState.value.copy(baud = it) },
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.timeout),
value = formState.value.timeout,
value = formState.value.timeout ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { timeout = it } },
onValueChanged = { formState.value = formState.value.copy(timeout = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.serial_mode),
enabled = state.connected,
items =
SerialConfig.Serial_Mode.entries
.filter { it != SerialConfig.Serial_Mode.UNRECOGNIZED }
.map { it to it.name },
selectedItem = formState.value.mode,
onItemSelected = { formState.value = formState.value.copy { mode = it } },
items = ModuleConfig.SerialConfig.Serial_Mode.entries.map { it to it.name },
selectedItem = formState.value.mode ?: ModuleConfig.SerialConfig.Serial_Mode.DEFAULT,
onItemSelected = { formState.value = formState.value.copy(mode = it) },
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.override_console_serial_port),
checked = formState.value.overrideConsoleSerialPort,
checked = formState.value.override_console_serial_port ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { overrideConsoleSerialPort = it } },
onCheckedChange = { formState.value = formState.value.copy(override_console_serial_port = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.foundation.layout.Column
@ -45,7 +44,7 @@ import org.meshtastic.core.strings.send
import org.meshtastic.core.strings.shutdown_node_name
import org.meshtastic.core.strings.shutdown_warning
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.User
@Composable
fun ShutdownConfirmationDialog(
@ -56,7 +55,7 @@ fun ShutdownConfirmationDialog(
icon: ImageVector? = Icons.Rounded.Warning,
onConfirm: () -> Unit,
) {
val nodeLongName = node?.user?.longName ?: "Unknown Node"
val nodeLongName = node?.user?.long_name ?: "Unknown Node"
AlertDialog(
onDismissRequest = {},
@ -104,11 +103,7 @@ private fun ShutdownDialogContent(nodeLongName: String, isShutdown: Boolean) {
@Preview
@Composable
private fun ShutdownConfirmationDialogPreview() {
val mockNode =
Node(
num = 123,
user = MeshProtos.User.newBuilder().setLongName("Rooftop Router Node").setShortName("ROOF").build(),
)
val mockNode = Node(num = 123, user = User(long_name = "Rooftop Router Node", short_name = "ROOF"))
AppTheme { ShutdownConfirmationDialog(title = "Shutdown?", node = mockNode, onDismiss = {}, onConfirm = {}) }
}

View file

@ -33,13 +33,12 @@ import org.meshtastic.core.strings.status_message_config
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.copy
import org.meshtastic.proto.moduleConfig
@Composable
fun StatusMessageConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val statusMessageConfig = state.moduleConfig.statusmessage
val statusMessageConfig =
state.moduleConfig.statusmessage ?: org.meshtastic.proto.ModuleConfig.StatusMessageConfig()
val formState = rememberConfigState(initialValue = statusMessageConfig)
val focusManager = LocalFocusManager.current
@ -51,7 +50,7 @@ fun StatusMessageConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(),
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { statusmessage = it }
val config = org.meshtastic.proto.ModuleConfig(statusmessage = it)
viewModel.setModuleConfig(config)
},
) {
@ -59,14 +58,14 @@ fun StatusMessageConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(),
TitledCard(title = stringResource(Res.string.status_message_config)) {
EditTextPreference(
title = stringResource(Res.string.node_status_summary),
value = formState.value.nodeStatus,
value = formState.value.node_status ?: "",
maxSize = 80, // status_message max_size:80
enabled = state.connected,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { nodeStatus = it } },
onValueChanged = { formState.value = formState.value.copy(node_status = it) },
)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.foundation.text.KeyboardActions
@ -39,13 +38,12 @@ import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.copy
import org.meshtastic.proto.moduleConfig
import org.meshtastic.proto.ModuleConfig
@Composable
fun StoreForwardConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val storeForwardConfig = state.moduleConfig.storeForward
val storeForwardConfig = state.moduleConfig.store_forward ?: ModuleConfig.StoreForwardConfig()
val formState = rememberConfigState(initialValue = storeForwardConfig)
val focusManager = LocalFocusManager.current
@ -57,7 +55,7 @@ fun StoreForwardConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(),
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { storeForward = it }
val config = ModuleConfig(store_forward = it)
viewModel.setModuleConfig(config)
},
) {
@ -65,49 +63,49 @@ fun StoreForwardConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(),
TitledCard(title = stringResource(Res.string.store_forward_config)) {
SwitchPreference(
title = stringResource(Res.string.store_forward_enabled),
checked = formState.value.enabled,
checked = formState.value.enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { this.enabled = it } },
onCheckedChange = { formState.value = formState.value.copy(enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.heartbeat),
checked = formState.value.heartbeat,
checked = formState.value.heartbeat ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { heartbeat = it } },
onCheckedChange = { formState.value = formState.value.copy(heartbeat = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.number_of_records),
value = formState.value.records,
value = formState.value.records ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { records = it } },
onValueChanged = { formState.value = formState.value.copy(records = it) },
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.history_return_max),
value = formState.value.historyReturnMax,
value = formState.value.history_return_max ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { historyReturnMax = it } },
onValueChanged = { formState.value = formState.value.copy(history_return_max = it) },
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.history_return_window),
value = formState.value.historyReturnWindow,
value = formState.value.history_return_window ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { historyReturnWindow = it } },
onValueChanged = { formState.value = formState.value.copy(history_return_window = it) },
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.server),
checked = formState.value.isServer,
checked = formState.value.is_server ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { isServer = it } },
onCheckedChange = { formState.value = formState.value.copy(is_server = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}

View file

@ -46,16 +46,16 @@ import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.util.IntervalConfiguration
import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.copy
import org.meshtastic.proto.moduleConfig
import org.meshtastic.proto.ModuleConfig
@Composable
fun TelemetryConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val telemetryConfig = state.moduleConfig.telemetry
val telemetryConfig = state.moduleConfig.telemetry ?: ModuleConfig.TelemetryConfig()
val formState = rememberConfigState(initialValue = telemetryConfig)
val capabilities = remember(state.metadata?.firmwareVersion) { Capabilities(state.metadata?.firmwareVersion) }
val firmwareVersion = state.metadata?.firmware_version
val capabilities = remember(firmwareVersion) { Capabilities(firmwareVersion) }
RadioConfigScreenList(
title = stringResource(Res.string.telemetry),
@ -65,7 +65,7 @@ fun TelemetryConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onB
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = moduleConfig { telemetry = it }
val config = ModuleConfig(telemetry = it)
viewModel.setModuleConfig(config)
},
) {
@ -75,9 +75,9 @@ fun TelemetryConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onB
SwitchPreference(
title = stringResource(Res.string.device_telemetry_enabled),
summary = stringResource(Res.string.device_telemetry_enabled_summary),
checked = formState.value.deviceTelemetryEnabled,
checked = formState.value.device_telemetry_enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { deviceTelemetryEnabled = it } },
onCheckedChange = { formState.value = formState.value.copy(device_telemetry_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
@ -85,86 +85,86 @@ fun TelemetryConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onB
val items = remember { IntervalConfiguration.BROADCAST_SHORT.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.device_metrics_update_interval_seconds),
selectedItem = formState.value.deviceUpdateInterval.toLong(),
selectedItem = (formState.value.device_update_interval ?: 0).toLong(),
enabled = state.connected,
items = items.map { it.value to it.toDisplayString() },
onItemSelected = { formState.value = formState.value.copy { deviceUpdateInterval = it.toInt() } },
onItemSelected = { formState.value = formState.value.copy(device_update_interval = it.toInt()) },
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.environment_metrics_module_enabled),
checked = formState.value.environmentMeasurementEnabled,
checked = formState.value.environment_measurement_enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { environmentMeasurementEnabled = it } },
onCheckedChange = { formState.value = formState.value.copy(environment_measurement_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
val envItems = remember { IntervalConfiguration.BROADCAST_SHORT.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.environment_metrics_update_interval_seconds),
selectedItem = formState.value.environmentUpdateInterval.toLong(),
selectedItem = (formState.value.environment_update_interval ?: 0).toLong(),
enabled = state.connected,
items = envItems.map { it.value to it.toDisplayString() },
onItemSelected = {
formState.value = formState.value.copy { environmentUpdateInterval = it.toInt() }
formState.value = formState.value.copy(environment_update_interval = it.toInt())
},
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.environment_metrics_on_screen_enabled),
checked = formState.value.environmentScreenEnabled,
checked = formState.value.environment_screen_enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { environmentScreenEnabled = it } },
onCheckedChange = { formState.value = formState.value.copy(environment_screen_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.environment_metrics_use_fahrenheit),
checked = formState.value.environmentDisplayFahrenheit,
checked = formState.value.environment_display_fahrenheit ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { environmentDisplayFahrenheit = it } },
onCheckedChange = { formState.value = formState.value.copy(environment_display_fahrenheit = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.air_quality_metrics_module_enabled),
checked = formState.value.airQualityEnabled,
checked = formState.value.air_quality_enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { airQualityEnabled = it } },
onCheckedChange = { formState.value = formState.value.copy(air_quality_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
val airItems = remember { IntervalConfiguration.BROADCAST_SHORT.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.air_quality_metrics_update_interval_seconds),
selectedItem = formState.value.airQualityInterval.toLong(),
selectedItem = (formState.value.air_quality_interval ?: 0).toLong(),
enabled = state.connected,
items = airItems.map { it.value to it.toDisplayString() },
onItemSelected = { formState.value = formState.value.copy { airQualityInterval = it.toInt() } },
onItemSelected = { formState.value = formState.value.copy(air_quality_interval = it.toInt()) },
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.power_metrics_module_enabled),
checked = formState.value.powerMeasurementEnabled,
checked = formState.value.power_measurement_enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { powerMeasurementEnabled = it } },
onCheckedChange = { formState.value = formState.value.copy(power_measurement_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
val powerItems = remember { IntervalConfiguration.BROADCAST_SHORT.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.power_metrics_update_interval_seconds),
selectedItem = formState.value.powerUpdateInterval.toLong(),
selectedItem = (formState.value.power_update_interval ?: 0).toLong(),
enabled = state.connected,
items = powerItems.map { it.value to it.toDisplayString() },
onItemSelected = { formState.value = formState.value.copy { powerUpdateInterval = it.toInt() } },
onItemSelected = { formState.value = formState.value.copy(power_update_interval = it.toInt()) },
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.power_metrics_on_screen_enabled),
checked = formState.value.powerScreenEnabled,
checked = formState.value.power_screen_enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { powerScreenEnabled = it } },
onCheckedChange = { formState.value = formState.value.copy(power_screen_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}

View file

@ -47,17 +47,17 @@ import org.meshtastic.core.ui.component.RegularPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.copy
@Composable
fun UserConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val userConfig = state.userConfig
val formState = rememberConfigState(initialValue = userConfig)
val capabilities = remember(state.metadata?.firmwareVersion) { Capabilities(state.metadata?.firmwareVersion) }
val firmwareVersion = state.metadata?.firmware_version
val capabilities = remember(firmwareVersion) { Capabilities(firmwareVersion) }
val validLongName = formState.value.longName.isNotBlank()
val validShortName = formState.value.shortName.isNotBlank()
val validLongName = (formState.value.long_name ?: "").isNotBlank()
val validShortName = (formState.value.short_name ?: "").isNotBlank()
val validNames = validLongName && validShortName
val focusManager = LocalFocusManager.current
@ -74,37 +74,37 @@ fun UserConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack:
TitledCard(title = stringResource(Res.string.user_config)) {
RegularPreference(
title = stringResource(Res.string.node_id),
subtitle = formState.value.id,
subtitle = formState.value.id ?: "",
onClick = {},
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.long_name),
value = formState.value.longName,
value = formState.value.long_name ?: "",
maxSize = 39, // long_name max_size:40
enabled = state.connected,
isError = !validLongName,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { longName = it } },
onValueChanged = { formState.value = formState.value.copy(long_name = it) },
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.short_name),
value = formState.value.shortName,
value = formState.value.short_name ?: "",
maxSize = 4, // short_name max_size:5
enabled = state.connected,
isError = !validShortName,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy { shortName = it } },
onValueChanged = { formState.value = formState.value.copy(short_name = it) },
)
HorizontalDivider()
RegularPreference(
title = stringResource(Res.string.hardware_model),
subtitle = formState.value.hwModel.name,
subtitle = formState.value.hw_model?.name ?: "",
onClick = {},
)
HorizontalDivider()
@ -112,19 +112,19 @@ fun UserConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack:
title = stringResource(Res.string.unmessageable),
summary = stringResource(Res.string.unmonitored_or_infrastructure),
checked =
formState.value.isUnmessagable ||
(formState.value.is_unmessagable ?: false) ||
(!capabilities.canToggleUnmessageable && formState.value.role.isUnmessageableRole()),
enabled = formState.value.hasIsUnmessagable() || capabilities.canToggleUnmessageable,
onCheckedChange = { formState.value = formState.value.copy { isUnmessagable = it } },
enabled = formState.value.is_unmessagable != null || capabilities.canToggleUnmessageable,
onCheckedChange = { formState.value = formState.value.copy(is_unmessagable = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.licensed_amateur_radio),
summary = stringResource(Res.string.licensed_amateur_radio_text),
checked = formState.value.isLicensed,
checked = formState.value.is_licensed ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy { isLicensed = it } },
onCheckedChange = { formState.value = formState.value.copy(is_licensed = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}