feat: Add ESP32 Unified OTA update support (#4095)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
This commit is contained in:
James Rich 2026-01-14 21:22:30 -06:00 committed by GitHub
parent 6b5dd24249
commit 2a60480bd9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 3410 additions and 717 deletions

View file

@ -136,7 +136,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 isOtaCapable by settingsViewModel.isOtaCapable.collectAsStateWithLifecycle()
val destNode by viewModel.destNode.collectAsState()
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
var isWaiting by remember { mutableStateOf(false) }
@ -249,7 +249,7 @@ fun SettingsScreen(
isManaged = localConfig.security.isManaged,
node = destNode,
excludedModulesUnlocked = excludedModulesUnlocked,
isDfuCapable = isDfuCapable,
isOtaCapable = isOtaCapable,
onPreserveFavoritesToggle = { viewModel.setPreserveFavorites(it) },
onRouteClick = { route ->
isWaiting = true

View file

@ -17,6 +17,7 @@
package org.meshtastic.feature.settings
import android.app.Application
import android.icu.text.SimpleDateFormat
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -45,12 +46,14 @@ import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.positionToMeter
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.prefs.radio.isBle
import org.meshtastic.core.prefs.radio.isSerial
import org.meshtastic.core.prefs.radio.isTcp
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.ServiceRepository
@ -61,7 +64,6 @@ import org.meshtastic.proto.Portnums
import java.io.BufferedWriter
import java.io.FileNotFoundException
import java.io.FileWriter
import java.text.SimpleDateFormat
import java.util.Locale
import javax.inject.Inject
import kotlin.math.roundToInt
@ -118,15 +120,22 @@ constructor(
val appVersionName
get() = buildConfigProvider.versionName
val isDfuCapable: StateFlow<Boolean> =
val isOtaCapable: StateFlow<Boolean> =
combine(ourNodeInfo, serviceRepository.connectionState) { node, connectionState -> Pair(node, connectionState) }
.flatMapLatest { (node, connectionState) ->
if (node == null || !connectionState.isConnected()) {
flowOf(false)
} else if (radioPrefs.isBle() || radioPrefs.isSerial()) {
} else if (radioPrefs.isBle() || radioPrefs.isSerial() || radioPrefs.isTcp()) {
val hwModel = node.user.hwModel.number
val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel).getOrNull()
flow { emit(hw?.requiresDfu == true) }
// Support both Nordic DFU (requiresDfu) and ESP32 Unified OTA (supportsUnifiedOta)
val capabilities = Capabilities(node.metadata?.firmwareVersion)
// ESP32 Unified OTA is only supported via BLE or WiFi (TCP), not USB Serial.
val isEsp32OtaSupported =
hw?.supportsUnifiedOta == true && capabilities.supportsEsp32Ota && !radioPrefs.isSerial()
flow { emit(hw?.requiresDfu == true || isEsp32OtaSupported) }
} else {
flowOf(false)
}

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
import androidx.compose.foundation.layout.Arrangement
@ -88,7 +87,7 @@ fun RadioConfigItemList(
isManaged: Boolean,
node: Node? = null,
excludedModulesUnlocked: Boolean = false,
isDfuCapable: Boolean = false,
isOtaCapable: Boolean = false,
onPreserveFavoritesToggle: (Boolean) -> Unit = {},
onRouteClick: (Enum<*>) -> Unit = {},
onImport: () -> Unit = {},
@ -212,7 +211,7 @@ fun RadioConfigItemList(
ManagedMessage()
}
if (isDfuCapable && state.isLocal) {
if (isOtaCapable && state.isLocal) {
ListItem(
text = stringResource(Res.string.firmware_update_title),
leadingIcon = Icons.Rounded.SystemUpdate,