feat: Add firmware update module for Nordic nRF devices (#3782)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-11-24 13:02:53 -06:00 committed by GitHub
parent 3e4e9d5f29
commit 4b93065c7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1461 additions and 4 deletions

View file

@ -133,6 +133,7 @@ fun SettingsScreen(
val localConfig by settingsViewModel.localConfig.collectAsStateWithLifecycle()
val ourNode by settingsViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by settingsViewModel.isConnected.collectAsStateWithLifecycle(false)
val isDfuCapable by settingsViewModel.isDfuCapable.collectAsStateWithLifecycle()
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
var isWaiting by remember { mutableStateOf(false) }
@ -244,6 +245,7 @@ fun SettingsScreen(
state = state,
isManaged = localConfig.security.isManaged,
excludedModulesUnlocked = excludedModulesUnlocked,
isDfuCapable = isDfuCapable,
onPreserveFavoritesToggle = { viewModel.setPreserveFavorites(it) },
onRouteClick = { route ->
isWaiting = true

View file

@ -26,14 +26,17 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.data.repository.DeviceHardwareRepository
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
@ -44,6 +47,7 @@ import org.meshtastic.core.database.model.Node
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.positionToMeter
import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.ServiceRepository
@ -74,6 +78,8 @@ constructor(
private val uiPreferencesDataSource: UiPreferencesDataSource,
private val buildConfigProvider: BuildConfigProvider,
private val databaseManager: DatabaseManager,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val radioPrefs: RadioPrefs,
) : ViewModel() {
val myNodeInfo: StateFlow<MyNodeEntity?> = nodeRepository.myNodeInfo
@ -109,6 +115,28 @@ constructor(
val appVersionName
get() = buildConfigProvider.versionName
val isDfuCapable: StateFlow<Boolean> =
combine(ourNodeInfo, serviceRepository.connectionState) { node, connectionState -> Pair(node, connectionState) }
.flatMapLatest { (node, connectionState) ->
if (node == null || !connectionState.isConnected()) {
flowOf(false)
} else {
// Check BLE address
val address = radioPrefs.devAddr
if (address == null || !address.startsWith("x")) {
flowOf(false)
} else {
// Check hardware
val hwModel = node.user.hwModel.number
flow {
val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel).getOrNull()
emit(hw?.requiresDfu == true)
}
}
}
}
.stateInWhileSubscribed(initialValue = false)
// Device DB cache limit (bounded by DatabaseConstants)
val dbCacheLimit: StateFlow<Int> = databaseManager.cacheLimit

View file

@ -31,6 +31,7 @@ import androidx.compose.material.icons.rounded.PowerSettingsNew
import androidx.compose.material.icons.rounded.RestartAlt
import androidx.compose.material.icons.rounded.Restore
import androidx.compose.material.icons.rounded.Storage
import androidx.compose.material.icons.rounded.SystemUpdate
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
@ -48,6 +49,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.navigation.FirmwareRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.strings.Res
@ -59,6 +61,7 @@ import org.meshtastic.core.strings.debug_panel
import org.meshtastic.core.strings.device_configuration
import org.meshtastic.core.strings.export_configuration
import org.meshtastic.core.strings.factory_reset
import org.meshtastic.core.strings.firmware_update_title
import org.meshtastic.core.strings.import_configuration
import org.meshtastic.core.strings.message_device_managed
import org.meshtastic.core.strings.module_settings
@ -82,6 +85,7 @@ fun RadioConfigItemList(
state: RadioConfigState,
isManaged: Boolean,
excludedModulesUnlocked: Boolean = false,
isDfuCapable: Boolean = false,
onPreserveFavoritesToggle: (Boolean) -> Unit = {},
onRouteClick: (Enum<*>) -> Unit = {},
onImport: () -> Unit = {},
@ -194,6 +198,15 @@ fun RadioConfigItemList(
ManagedMessage()
}
if (isDfuCapable && state.isLocal) {
ListItem(
text = stringResource(Res.string.firmware_update_title),
leadingIcon = Icons.Rounded.SystemUpdate,
enabled = enabled,
onClick = { onNavigate(FirmwareRoutes.FirmwareUpdate) },
)
}
ListItem(
text = stringResource(Res.string.clean_node_database_title),
leadingIcon = Icons.Rounded.CleaningServices,