fix(firmware): Retry ESP32 OTA service connections cleanly (#6081)

This commit is contained in:
Jeremiah K
2026-07-08 22:07:25 -05:00
committed by GitHub
parent bd14228fb6
commit c2a8e67aee
16 changed files with 1297 additions and 105 deletions

View File

@@ -634,6 +634,7 @@ firmware_update_no_device
firmware_update_node_info_missing
firmware_update_not_found_in_release
firmware_update_ota_failed
firmware_update_ota_unsupported_reason
firmware_update_rak4631_bootloader_hint
firmware_update_rebooting
firmware_update_release_notes
@@ -643,6 +644,7 @@ firmware_update_requires_uf2
firmware_update_retrieval_failed
firmware_update_retry
firmware_update_save_dfu_file
firmware_update_searching_device
firmware_update_select_file
firmware_update_slow_bootloader_hint
firmware_update_source_local

View File

@@ -22,6 +22,8 @@ import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.model.util.isOtaStatusNotification
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
import org.meshtastic.core.repository.FromRadioPacketHandler
import org.meshtastic.core.repository.LockdownCoordinator
import org.meshtastic.core.repository.MeshConfigFlowManager
@@ -54,6 +56,7 @@ class FromRadioPacketHandlerImpl(
private val packetHandler: PacketHandler,
private val notificationManager: NotificationManager,
private val lockdownCoordinator: LockdownCoordinator,
private val firmwareUpdateStatusRepository: FirmwareUpdateStatusRepository,
) : FromRadioPacketHandler {
// Application-scoped coroutine context for suspend work (e.g. getStringSuspend).
@@ -132,6 +135,11 @@ class FromRadioPacketHandlerImpl(
serviceStateWriter.setClientNotification(cn)
scope.handledLaunch {
if (cn.isOtaStatusNotification() && firmwareUpdateStatusRepository.status.value.isOtaUpdateActive) {
Logger.i { "OTA status ClientNotification received; skipping duplicate generic alert" }
return@handledLaunch
}
val inform = cn.key_verification_number_inform
val request = cn.key_verification_number_request
val verificationFinal = cn.key_verification_final

View File

@@ -21,6 +21,8 @@ import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.mock
import dev.mokkery.verify
import org.meshtastic.core.model.util.isOtaStatusNotification
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MqttManager
@@ -43,6 +45,7 @@ import org.meshtastic.proto.QueueStatus
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import org.meshtastic.proto.NodeInfo as ProtoNodeInfo
@@ -56,6 +59,7 @@ class FromRadioPacketHandlerImplTest {
private val configHandler: MeshConfigHandler = mock(MockMode.autofill)
private val xmodemManager: XModemManager = mock(MockMode.autofill)
private val lockdownCoordinator = FakeLockdownCoordinator()
private val firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository()
private lateinit var handler: FromRadioPacketHandlerImpl
@@ -71,6 +75,7 @@ class FromRadioPacketHandlerImplTest {
packetHandler,
notificationManager,
lockdownCoordinator,
firmwareUpdateStatusRepository,
)
}
@@ -203,4 +208,22 @@ class FromRadioPacketHandlerImplTest {
verify { serviceRepository.setClientNotification(notification) }
}
@Test
fun `OTA status client notifications are identified`() {
assertTrue(ClientNotification(message = "Rebooting to WiFi OTA").isOtaStatusNotification())
assertTrue(ClientNotification(message = "OTA Loader does not support WiFi").isOtaStatusNotification())
assertTrue(
ClientNotification(message = "Cannot start OTA: OTA Loader partition not found.").isOtaStatusNotification(),
)
assertTrue(ClientNotification(message = "Unable to switch to the OTA partition.").isOtaStatusNotification())
}
@Test
fun `non OTA client notifications are not identified as OTA status`() {
assertFalse(ClientNotification(message = "test").isOtaStatusNotification())
assertFalse(ClientNotification(message = "Low battery").isOtaStatusNotification())
assertFalse(ClientNotification(message = "ROTATE credentials").isOtaStatusNotification())
assertFalse(ClientNotification(message = "Quota exceeded").isOtaStatusNotification())
}
}

View File

@@ -19,6 +19,7 @@
package org.meshtastic.core.model.util
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Config
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
@@ -139,3 +140,13 @@ fun getInitials(fullName: String): String {
}
fun String.withoutEmojis(): String = filterNot { char -> char.isSurrogate() }
private val OTA_PATTERN = Regex("\\bOTA\\b", RegexOption.IGNORE_CASE)
/**
* OTA-status detector matching the firmware update preflight gate's criterion: any ClientNotification whose message
* contains "OTA" as a whole word (e.g. "Rebooting to BLE OTA", "Cannot start OTA: ...") is consumed by the firmware
* update flow and suppressed as a generic alert. Uses word-boundary matching so unrelated messages containing the
* substring (e.g. "ROTATE", "QUOTA") are not misclassified.
*/
fun ClientNotification.isOtaStatusNotification(): Boolean = message.isNotBlank() && OTA_PATTERN.containsMatchIn(message)

View File

@@ -0,0 +1,45 @@
/*
* Copyright (c) 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
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* 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.core.repository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class FirmwareUpdateStatus(val isOtaUpdateActive: Boolean = false, val isAwaitingOtaStatus: Boolean = false)
class FirmwareUpdateStatusRepository {
private val _status = MutableStateFlow(FirmwareUpdateStatus())
val status: StateFlow<FirmwareUpdateStatus> = _status.asStateFlow()
fun beginOtaUpdate() {
_status.value = FirmwareUpdateStatus(isOtaUpdateActive = true)
}
fun beginOtaPreflight() {
_status.value = FirmwareUpdateStatus(isOtaUpdateActive = true, isAwaitingOtaStatus = true)
}
fun finishOtaPreflight() {
_status.update { it.copy(isAwaitingOtaStatus = false) }
}
fun endOtaUpdate() {
_status.value = FirmwareUpdateStatus()
}
}

View File

@@ -20,6 +20,7 @@ import org.koin.core.annotation.Module
import org.koin.core.annotation.Provided
import org.koin.core.annotation.Single
import org.meshtastic.core.common.di.ApplicationCoroutineScope
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.MeshBeaconPrefs
import org.meshtastic.core.repository.MeshBeaconRepository
@@ -38,6 +39,9 @@ class CoreRepositoryModule {
@Provided applicationScope: ApplicationCoroutineScope,
): MeshBeaconRepository = MeshBeaconRepository(meshBeaconPrefs, applicationScope)
@Single
fun provideFirmwareUpdateStatusRepository(): FirmwareUpdateStatusRepository = FirmwareUpdateStatusRepository()
@Single
fun provideSendMessageUseCase(
@Provided nodeRepository: NodeRepository,

View File

@@ -658,7 +658,8 @@
<string name="firmware_update_node_info_missing">Node user information is missing.</string>
<string name="firmware_update_not_found_in_release">Could not find firmware for %1$s in release.</string>
<string name="firmware_update_ota_failed">OTA update failed: %1$s</string>
<string name="firmware_update_rak4631_bootloader_hint">For RAK WisBlock RAK4631, use the vendor's serial DFU tool (for example, adafruit-nrfutil dfu serial with the provided bootloader .zip file). Copying the .uf2 file alone will not update the bootloader.</string>
<string name="firmware_update_ota_unsupported_reason">This device\'s OTA loader rejected the requested update method: %1$s</string>
<string name="firmware_update_rak4631_bootloader_hint">For RAK WisBlock RAK4631, use the vendor\'s serial DFU tool (for example, adafruit-nrfutil dfu serial with the provided bootloader .zip file). Copying the .uf2 file alone will not update the bootloader.</string>
<string name="firmware_update_rebooting">Rebooting to DFU...</string>
<string name="firmware_update_release_notes">Release Notes</string>
<string name="firmware_update_requires_bin">%1$s requires a target-matching .bin file.</string>
@@ -667,6 +668,7 @@
<string name="firmware_update_retrieval_failed">Could not retrieve firmware file.</string>
<string name="firmware_update_retry">Retry</string>
<string name="firmware_update_save_dfu_file">Please save the .uf2 file to your device's DFU drive.</string>
<string name="firmware_update_searching_device">Searching for OTA device on the network...</string>
<string name="firmware_update_select_file">Select Local File</string>
<string name="firmware_update_slow_bootloader_hint">Updates are slow on this device's bootloader. Flashing the OTAFIX bootloader enables much faster BLE updates.</string>
<string name="firmware_update_source_local">Source: Local File</string>

View File

@@ -212,4 +212,8 @@ class FakeRadioController :
fun setConnectionState(state: ConnectionState) {
_connectionState.value = state
}
fun setClientNotification(notification: ClientNotification?) {
_clientNotification.value = notification
}
}

View File

@@ -47,9 +47,11 @@ import org.meshtastic.core.model.TracerouteMapAvailability
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.service.TracerouteResponse
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.model.util.isOtaStatusNotification
import org.meshtastic.core.navigation.DeepLinkRouter
import org.meshtastic.core.repository.EventFirmwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
import org.meshtastic.core.repository.LockdownCoordinator
import org.meshtastic.core.repository.LockdownPassphraseStore
import org.meshtastic.core.repository.MeshLogRepository
@@ -88,6 +90,7 @@ class UIViewModel(
meshLogRepository: MeshLogRepository,
firmwareReleaseRepository: FirmwareReleaseRepository,
private val eventFirmwareRepository: EventFirmwareRepository,
private val firmwareUpdateStatusRepository: FirmwareUpdateStatusRepository,
private val uiPrefs: UiPrefs,
private val notificationManager: NotificationManager,
packetRepository: PacketRepository,
@@ -261,6 +264,14 @@ class UIViewModel(
serviceRepository.clientNotification
.filterNotNull()
.onEach { notification ->
val firmwareUpdateStatus = firmwareUpdateStatusRepository.status.value
if (notification.isOtaStatusNotification() && firmwareUpdateStatus.isOtaUpdateActive) {
Logger.i { "Suppressing OTA status ClientNotification generic alert during firmware update" }
if (!firmwareUpdateStatus.isAwaitingOtaStatus) {
clearClientNotification(notification)
}
return@onEach
}
val isCompromised = notification.low_entropy_key != null || notification.duplicated_public_key != null
showAlert(
titleRes = Res.string.client_notification,

View File

@@ -17,6 +17,8 @@
package org.meshtastic.core.ui.viewmodel
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.model.util.isOtaStatusNotification
import org.meshtastic.proto.ClientNotification
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@@ -46,4 +48,23 @@ class UIViewModelImportSummaryTest {
assertTrue(summary.contains("hasFragment=false"))
}
@Test
fun ota_status_notifications_are_suppressed() {
assertTrue(ClientNotification(message = "Rebooting to WiFi OTA").isOtaStatusNotification())
assertTrue(ClientNotification(message = "Rebooting to BLE OTA").isOtaStatusNotification())
assertTrue(
ClientNotification(message = "Cannot start OTA: OTA Loader partition not found.").isOtaStatusNotification(),
)
assertTrue(ClientNotification(message = "OTA Loader does not support WiFi").isOtaStatusNotification())
assertTrue(ClientNotification(message = "Unable to switch to the OTA partition.").isOtaStatusNotification())
}
@Test
fun non_ota_status_notifications_are_not_suppressed() {
assertFalse(ClientNotification(message = "Low battery").isOtaStatusNotification())
assertFalse(ClientNotification(message = "Key verification requested").isOtaStatusNotification())
assertFalse(ClientNotification(message = "ROTATE credentials").isOtaStatusNotification())
assertFalse(ClientNotification(message = "Quota exceeded").isOtaStatusNotification())
}
}

View File

@@ -18,8 +18,11 @@ package org.meshtastic.feature.firmware.ota
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.annotation.Single
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleScanner
@@ -28,6 +31,8 @@ import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.util.isOtaStatusNotification
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.resources.Res
@@ -39,6 +44,8 @@ import org.meshtastic.core.resources.firmware_update_extracting
import org.meshtastic.core.resources.firmware_update_hash_rejected
import org.meshtastic.core.resources.firmware_update_not_found_in_release
import org.meshtastic.core.resources.firmware_update_ota_failed
import org.meshtastic.core.resources.firmware_update_ota_unsupported_reason
import org.meshtastic.core.resources.firmware_update_searching_device
import org.meshtastic.core.resources.firmware_update_starting_ota
import org.meshtastic.core.resources.firmware_update_uploading
import org.meshtastic.core.resources.firmware_update_waiting_reboot
@@ -53,17 +60,79 @@ import org.meshtastic.feature.firmware.stripFormatArgs
private const val RETRY_DELAY = 2000L
private const val PERCENT_MAX = 100
private const val REBOOT_MODE_BLE = 1
private const val REBOOT_MODE_WIFI = 2
// Time to wait for OTA reboot packet to be sent before disconnecting mesh service
private const val PACKET_SEND_DELAY_MS = 2000L
// Time to wait for the firmware to confirm OTA entry before tearing down the mesh transport.
// The firmware emits a ClientNotification ("Rebooting to <mode> OTA" on success, a "Cannot start OTA: ..." /
// "OTA Loader does not support ..." warning on rejection) before its 1 s scheduled reboot — so this only needs to
// cover BLE/TCP round-trip + device processing, not the reboot itself.
private const val OTA_PREFLIGHT_TIMEOUT_MS = 5000L
// Time to wait for BLE GATT to fully release after disconnecting mesh service
private const val GATT_RELEASE_DELAY_MS = 1000L
// Wi-Fi OTA loader needs time to reboot, rejoin the network, and start its TCP OTA server after confirmation.
private const val WIFI_OTA_READINESS_DELAY_MS = 8_000L
// Firmware emits one of these human-readable messages via meshtastic.ClientNotification before rebooting.
// "Rebooting to BLE OTA" / "Rebooting to WiFi OTA" -> success. Any other OTA status message -> rejection.
private const val OTA_CONFIRM_PREFIX = "Rebooting to"
// A BLE target is a 6-octet MAC (e.g. AA:BB:CC:DD:EE:FF). Matching the exact MAC shape — not merely "contains a colon"
// — keeps an IPv6 WiFi target (which also has colons) from being misrouted to the BLE path.
private val MAC_ADDRESS_REGEX = Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")
private data class TransportFactoryResolution(
val factory: () -> UnifiedOtaProtocol,
val readinessAlreadyWaited: Boolean,
)
interface Esp32OtaUpdateEnvironment {
val otaPreflightTimeoutMs: Long
val otaTransportRetryDelayMs: Long
val wifiOtaReadinessDelayMs: Long
val gattReleaseDelayMs: Long
val wifiDiscoveryEnabled: Boolean
suspend fun delay(milliseconds: Long)
fun createBleTransport(
bleScanner: BleScanner,
bleConnectionFactory: BleConnectionFactory,
address: String,
dispatcher: CoroutineDispatcher,
): UnifiedOtaProtocol
fun createWifiTransport(deviceIp: String): UnifiedOtaProtocol
suspend fun discoverWifiOtaDevice(): String?
}
@Single(binds = [Esp32OtaUpdateEnvironment::class])
class DefaultEsp32OtaUpdateEnvironment : Esp32OtaUpdateEnvironment {
override val otaPreflightTimeoutMs: Long = OTA_PREFLIGHT_TIMEOUT_MS
override val otaTransportRetryDelayMs: Long = RETRY_DELAY
override val wifiOtaReadinessDelayMs: Long = WIFI_OTA_READINESS_DELAY_MS
override val gattReleaseDelayMs: Long = GATT_RELEASE_DELAY_MS
override val wifiDiscoveryEnabled: Boolean = true
override suspend fun delay(milliseconds: Long) {
kotlinx.coroutines.delay(milliseconds)
}
override fun createBleTransport(
bleScanner: BleScanner,
bleConnectionFactory: BleConnectionFactory,
address: String,
dispatcher: CoroutineDispatcher,
): UnifiedOtaProtocol = BleOtaTransport(bleScanner, bleConnectionFactory, address, dispatcher)
override fun createWifiTransport(deviceIp: String): UnifiedOtaProtocol = WifiOtaTransport(deviceIp)
override suspend fun discoverWifiOtaDevice(): String? = WifiOtaDiscovery.discoverOtaDevice()
}
internal fun isBleMacAddress(target: String): Boolean = MAC_ADDRESS_REGEX.matches(target)
/**
@@ -79,6 +148,8 @@ class Esp32OtaUpdateHandler(
private val firmwareFileHandler: FirmwareFileHandler,
private val radioController: RadioController,
private val nodeRepository: NodeRepository,
private val firmwareUpdateStatusRepository: FirmwareUpdateStatusRepository,
private val environment: Esp32OtaUpdateEnvironment,
private val bleScanner: BleScanner,
private val bleConnectionFactory: BleConnectionFactory,
private val dispatchers: CoroutineDispatchers,
@@ -108,9 +179,12 @@ class Esp32OtaUpdateHandler(
hardware = hardware,
updateState = updateState,
firmwareUri = firmwareUri,
transportFactory = { BleOtaTransport(bleScanner, bleConnectionFactory, address, dispatchers.default) },
rebootMode = 1,
transportFactory = {
environment.createBleTransport(bleScanner, bleConnectionFactory, address, dispatchers.default)
},
rebootMode = REBOOT_MODE_BLE,
connectionAttempts = 5,
postConfirmReadinessDelayMs = 0L,
)
private suspend fun startWifiUpdate(
@@ -124,9 +198,10 @@ class Esp32OtaUpdateHandler(
hardware = hardware,
updateState = updateState,
firmwareUri = firmwareUri,
transportFactory = { WifiOtaTransport(deviceIp, WifiOtaTransport.DEFAULT_PORT) },
rebootMode = 2,
transportFactory = { environment.createWifiTransport(deviceIp) },
rebootMode = REBOOT_MODE_WIFI,
connectionAttempts = 10,
postConfirmReadinessDelayMs = environment.wifiOtaReadinessDelayMs,
)
private suspend fun performUpdate(
@@ -137,56 +212,113 @@ class Esp32OtaUpdateHandler(
transportFactory: () -> UnifiedOtaProtocol,
rebootMode: Int,
connectionAttempts: Int,
postConfirmReadinessDelayMs: Long,
): FirmwareArtifact? {
firmwareUpdateStatusRepository.beginOtaUpdate()
var cleanupArtifact: FirmwareArtifact? = null
return try {
withContext(ioDispatcher) {
// Step 1: Get firmware file
cleanupArtifact = obtainFirmwareFile(release, hardware, firmwareUri, updateState)
val firmwareFile = cleanupArtifact ?: return@withContext null
// Step 2: Read firmware once and calculate hash
val firmwareBytes = firmwareFileHandler.readBytes(firmwareFile)
val sha256Bytes = FirmwareHashUtil.calculateSha256Bytes(firmwareBytes)
val sha256Hash = FirmwareHashUtil.bytesToHex(sha256Bytes)
Logger.i { "ESP32 OTA: Firmware hash: $sha256Hash (${firmwareBytes.size} bytes)" }
triggerRebootOta(rebootMode, sha256Bytes)
// Step 3: Wait for packet to be sent, then disconnect mesh service
// The packet needs ~1-2 seconds to be written and acknowledged over BLE
delay(PACKET_SEND_DELAY_MS)
disconnectMeshService()
// Give BLE stack time to fully release the GATT connection
delay(GATT_RELEASE_DELAY_MS)
val transport = transportFactory()
connectToDevice(transport, connectionAttempts, updateState)
try {
executeOtaSequence(transport, firmwareBytes, sha256Hash, rebootMode, updateState)
firmwareFile
} finally {
transport.close()
try {
withContext(ioDispatcher) {
performUpdateFlow(
release,
hardware,
firmwareUri,
updateState,
transportFactory,
rebootMode,
connectionAttempts,
postConfirmReadinessDelayMs,
)
.also { cleanupArtifact = it }
}
} catch (e: CancellationException) {
throw e
} catch (e: OtaProtocolException.HashRejected) {
Logger.e(e) { "ESP32 OTA: Hash rejected by device" }
updateState(FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_hash_rejected)))
cleanupArtifact
} catch (e: OtaProtocolException) {
Logger.e(e) { "ESP32 OTA: Protocol error" }
updateState(
FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_ota_failed, e.message ?: "")),
)
cleanupArtifact
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.e(e) { "ESP32 OTA: Unexpected error" }
updateState(
FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_ota_failed, e.message ?: "")),
)
cleanupArtifact
}
} catch (e: CancellationException) {
throw e
} catch (e: OtaProtocolException.HashRejected) {
Logger.e(e) { "ESP32 OTA: Hash rejected by device" }
updateState(FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_hash_rejected)))
cleanupArtifact
} catch (e: OtaProtocolException) {
Logger.e(e) { "ESP32 OTA: Protocol error" }
updateState(
FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_ota_failed, e.message ?: "")),
} finally {
firmwareUpdateStatusRepository.endOtaUpdate()
}
}
// Extracted from performUpdate to stay under detekt LongMethod threshold (60). Single linear OTA flow:
// obtain file → hash → preflight (clearClientNotification + trigger + runOtaPreflight) → connect → execute.
@Suppress("ReturnCount") // three distinct early-exit paths in a linear pipeline
private suspend fun performUpdateFlow(
release: FirmwareRelease,
hardware: DeviceHardware,
firmwareUri: CommonUri?,
updateState: (FirmwareUpdateState) -> Unit,
transportFactory: () -> UnifiedOtaProtocol,
rebootMode: Int,
connectionAttempts: Int,
postConfirmReadinessDelayMs: Long,
): FirmwareArtifact? {
// Step 1: Get firmware file
val firmwareFile = obtainFirmwareFile(release, hardware, firmwareUri, updateState) ?: return null
// Step 2: Read firmware once and calculate hash
val firmwareBytes = firmwareFileHandler.readBytes(firmwareFile)
val sha256Bytes = FirmwareHashUtil.calculateSha256Bytes(firmwareBytes)
val sha256Hash = FirmwareHashUtil.bytesToHex(sha256Bytes)
Logger.i { "ESP32 OTA: Firmware hash: $sha256Hash (${firmwareBytes.size} bytes)" }
firmwareUpdateStatusRepository.beginOtaPreflight()
try {
// Clear any stale notification from a previous attempt so the firmware's response is always
// treated as a fresh emission (StateFlow conflates identical values — a retry would otherwise
// time out waiting for a value that the flow already holds).
radioController.clearClientNotification()
// Snapshot the current notification message BEFORE sending the OTA trigger. After the clear
// above this is null today, but capturing it remains defense-in-depth: if the clear is ever
// removed or the value is repopulated between the clear and the trigger, the predicate must
// still distinguish the response from a pre-existing value.
val baselineMessage = radioController.clientNotification.value?.message
triggerRebootOta(rebootMode, sha256Bytes)
// Step 3: Preflight — wait for the firmware to confirm OTA entry BEFORE tearing down the mesh
// transport. The mesh ACK only confirms packet delivery; it does NOT mean the device's OTA
// loader supports the requested transport. The firmware surfaces loader-capability failures as
// a meshtastic.ClientNotification warning ("OTA Loader does not support <mode>", etc.) emitted
// before its scheduled reboot, so we race that signal against a bounded timeout. On rejection
// we fail fast and leave the mesh connection intact. On timeout we preserve legacy behavior by
// releasing the mesh transport and trying the OTA path; older firmware may be silent.
if (!runOtaPreflight(rebootMode, baselineMessage, updateState).continueToOtaTransport) {
return firmwareFile
}
} finally {
firmwareUpdateStatusRepository.finishOtaPreflight()
}
val transport =
connectToDevice(
transportFactory = transportFactory,
attempts = connectionAttempts,
rebootMode = rebootMode,
postConfirmReadinessDelayMs = postConfirmReadinessDelayMs,
updateState = updateState,
)
cleanupArtifact
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.e(e) { "ESP32 OTA: Unexpected error" }
updateState(
FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_ota_failed, e.message ?: "")),
)
cleanupArtifact
try {
executeOtaSequence(transport, firmwareBytes, sha256Hash, rebootMode, updateState)
return firmwareFile
} finally {
transport.close()
}
}
@@ -211,7 +343,14 @@ class Esp32OtaUpdateHandler(
hardware: DeviceHardware,
firmwareUri: CommonUri?,
updateState: (FirmwareUpdateState) -> Unit,
): FirmwareArtifact? {
): FirmwareArtifact? = if (firmwareUri != null) {
updateState(
FirmwareUpdateState.Processing(
ProgressState(message = UiText.Resource(Res.string.firmware_update_extracting)),
),
)
firmwareFileHandler.importFromUri(firmwareUri)
} else {
val downloadingMsg = getStringSuspend(Res.string.firmware_update_downloading_percent, 0).stripFormatArgs()
updateState(
@@ -220,65 +359,164 @@ class Esp32OtaUpdateHandler(
),
)
return if (firmwareUri != null) {
updateState(
FirmwareUpdateState.Processing(
ProgressState(message = UiText.Resource(Res.string.firmware_update_extracting)),
),
)
firmwareFileHandler.importFromUri(firmwareUri)
} else {
val firmwareFile =
firmwareRetriever.retrieveEsp32Firmware(release, hardware) { progress ->
val percent = (progress * PERCENT_MAX).toInt()
updateState(
FirmwareUpdateState.Downloading(
ProgressState(
message = UiText.DynamicString(downloadingMsg),
progress = progress,
details = "$percent%",
),
),
)
}
if (firmwareFile == null) {
val firmwareFile =
firmwareRetriever.retrieveEsp32Firmware(release, hardware) { progress ->
val percent = (progress * PERCENT_MAX).toInt()
updateState(
FirmwareUpdateState.Error(
UiText.Resource(Res.string.firmware_update_not_found_in_release, hardware.displayName),
FirmwareUpdateState.Downloading(
ProgressState(
message = UiText.DynamicString(downloadingMsg),
progress = progress,
details = "$percent%",
),
),
)
null
} else {
firmwareFile
}
if (firmwareFile == null) {
updateState(
FirmwareUpdateState.Error(
UiText.Resource(Res.string.firmware_update_not_found_in_release, hardware.displayName),
),
)
null
} else {
firmwareFile
}
}
private suspend fun connectToDevice(
transport: UnifiedOtaProtocol,
transportFactory: () -> UnifiedOtaProtocol,
attempts: Int,
rebootMode: Int,
postConfirmReadinessDelayMs: Long,
updateState: (FirmwareUpdateState) -> Unit,
) {
// Show "waiting for reboot" state before first connection attempt
): UnifiedOtaProtocol {
updateState(
FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_waiting_reboot))),
)
retryWithDelay(
attempts = attempts,
retryDelayMillis = RETRY_DELAY,
onAttempt = { i ->
updateState(
FirmwareUpdateState.Processing(
ProgressState(UiText.Resource(Res.string.firmware_update_connecting_attempt, i, attempts)),
),
)
},
) {
transport.connect()
val resolution = resolvePostConfirmTransportFactory(rebootMode, transportFactory, updateState)
waitForPostConfirmReadiness(rebootMode, postConfirmReadinessDelayMs, resolution.readinessAlreadyWaited)
return runTransportConnectRetries(resolution.factory, attempts, rebootMode, updateState)
}
private suspend fun resolvePostConfirmTransportFactory(
rebootMode: Int,
transportFactory: () -> UnifiedOtaProtocol,
updateState: (FirmwareUpdateState) -> Unit,
): TransportFactoryResolution {
// In WiFi mode the device may have picked up a different DHCP lease after rebooting into the OTA loader. Listen
// for the loader's UDP discovery broadcast and, if one arrives, redirect the TCP transport at the discovered
// IP.
if (rebootMode != REBOOT_MODE_WIFI || !environment.wifiDiscoveryEnabled) {
return TransportFactoryResolution(factory = transportFactory, readinessAlreadyWaited = false)
}
.getOrThrow()
updateState(
FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_searching_device))),
)
val discoveredIp = environment.discoverWifiOtaDevice()
val factory: () -> UnifiedOtaProtocol =
if (discoveredIp != null) {
val discoveredFactory: () -> UnifiedOtaProtocol = { environment.createWifiTransport(discoveredIp) }
Logger.i { "ESP32 OTA: Using UDP-discovered OTA device for TCP transport" }
discoveredFactory
} else {
Logger.i { "ESP32 OTA: No UDP discovery broadcast received; falling back to configured device IP" }
transportFactory
}
// Only treat the readiness window as already-spent when discovery actually resolved a device — its bounded
// listening window covers the loader's reboot+DHCP+TCP-server bring-up. On a null result (timeout / bind
// failure) the device has not yet been heard from, so the caller's 8 s readiness margin still applies.
return TransportFactoryResolution(factory = factory, readinessAlreadyWaited = discoveredIp != null)
}
private suspend fun waitForPostConfirmReadiness(rebootMode: Int, delayMs: Long, readinessAlreadyWaited: Boolean) {
if (delayMs <= 0L || readinessAlreadyWaited) return
Logger.i { "ESP32 OTA: Waiting ${delayMs}ms for ${otaModeName(rebootMode)} OTA service" }
environment.delay(delayMs)
}
private suspend fun runTransportConnectRetries(
transportFactory: () -> UnifiedOtaProtocol,
attempts: Int,
rebootMode: Int,
updateState: (FirmwareUpdateState) -> Unit,
): UnifiedOtaProtocol {
var connectedTransport: UnifiedOtaProtocol? = null
val result =
retryWithDelay(
attempts = attempts,
retryDelayMillis = environment.otaTransportRetryDelayMs,
onAttempt = { i ->
updateState(
FirmwareUpdateState.Processing(
ProgressState(UiText.Resource(Res.string.firmware_update_connecting_attempt, i, attempts)),
),
)
},
) {
val transport = transportFactory()
val connectResult = connectTransportAttempt(transport, rebootMode)
connectResult.onSuccess { connectedTransport = transport }
}
result.getOrElse { cause -> throw postConfirmConnectionFailed(rebootMode, attempts, cause) }
return connectedTransport ?: throw postConfirmConnectionFailed(rebootMode, attempts, null)
}
private suspend fun connectTransportAttempt(transport: UnifiedOtaProtocol, rebootMode: Int): Result<Unit> {
val connectResult =
try {
transport.connect()
} catch (e: CancellationException) {
closeFailedTransport(transport)
throw e
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Result.failure(e)
}
val error = connectResult.exceptionOrNull() ?: return connectResult
if (error is CancellationException) {
closeFailedTransport(transport)
throw error
}
Logger.w(error) {
"ESP32 OTA: ${otaModeName(rebootMode)} connection attempt failed; closing transport before retry"
}
closeFailedTransport(transport)
return Result.failure(error)
}
private suspend fun closeFailedTransport(transport: UnifiedOtaProtocol) {
withContext(NonCancellable) {
try {
transport.close()
} catch (@Suppress("TooGenericExceptionCaught") e: Throwable) {
Logger.w(e) { "ESP32 OTA: Failed to close failed transport attempt" }
}
}
}
private fun postConfirmConnectionFailed(
rebootMode: Int,
attempts: Int,
cause: Throwable?,
): OtaProtocolException.ConnectionFailed {
val modeName = otaModeName(rebootMode)
return OtaProtocolException.ConnectionFailed(
"Device confirmed $modeName OTA mode, but the OTA service was not reachable after $attempts attempts. " +
connectionRecoveryHint(rebootMode),
cause,
)
}
private fun connectionRecoveryHint(rebootMode: Int): String = when (rebootMode) {
REBOOT_MODE_WIFI -> "Make sure the phone and device are on the same network and try again."
REBOOT_MODE_BLE -> "Keep the device nearby and try again."
else -> "Try again."
}
@Suppress("LongMethod")
@@ -312,7 +550,7 @@ class Esp32OtaUpdateHandler(
val uploadingMsg = UiText.Resource(Res.string.firmware_update_uploading)
updateState(FirmwareUpdateState.Updating(ProgressState(uploadingMsg, 0f)))
val chunkSize =
if (rebootMode == 1) {
if (rebootMode == REBOOT_MODE_BLE) {
BleOtaTransport.RECOMMENDED_CHUNK_SIZE
} else {
WifiOtaTransport.RECOMMENDED_CHUNK_SIZE
@@ -340,4 +578,114 @@ class Esp32OtaUpdateHandler(
updateState(FirmwareUpdateState.Success())
}
/**
* Outcome of waiting for the device to confirm OTA entry after [triggerRebootOta]. The firmware emits a single
* [org.meshtastic.proto.ClientNotification] before its scheduled reboot covering all cases: success ("Rebooting to
* <BLE|WiFi> OTA") or rejection (any other OTA status message — partition missing, loader incompatible, switch
* failed). Older firmware may be silent, which falls back to the previous disconnect/reconnect path.
*/
private sealed interface OtaPreflightResult {
val continueToOtaTransport: Boolean
/** Firmware confirmed it is rebooting into the OTA loader. */
data object Confirmed : OtaPreflightResult {
override val continueToOtaTransport = true
}
/** Firmware rejected the OTA request; [message] is the verbatim rejection reason. */
data class Rejected(val message: String) : OtaPreflightResult {
override val continueToOtaTransport = false
}
/** No ClientNotification arrived within the bound; continue with the legacy fixed-delay path. */
data object LegacyFallback : OtaPreflightResult {
override val continueToOtaTransport = true
}
}
/**
* Run the OTA preflight gate. On [OtaPreflightResult.Confirmed] this releases the mesh transport (with the GATT
* release delay) and returns Confirmed. On rejection it emits [FirmwareUpdateState.Error] and returns the failure
* result so the caller can abort while preserving the cleanup artifact and mesh transport. On legacy fallback it
* releases the mesh transport and continues to the OTA connection attempts just as older app versions did.
*/
private suspend fun runOtaPreflight(
rebootMode: Int,
baselineMessage: String?,
updateState: (FirmwareUpdateState) -> Unit,
): OtaPreflightResult =
when (val preflight = awaitOtaConfirmation(environment.otaPreflightTimeoutMs, rebootMode, baselineMessage)) {
is OtaPreflightResult.Confirmed -> {
Logger.i { "ESP32 OTA: Preflight confirmed; releasing mesh transport for OTA" }
releaseMeshTransportForOta()
preflight
}
is OtaPreflightResult.Rejected -> {
Logger.w { "ESP32 OTA: Firmware rejected OTA entry (${preflight.message}); mesh transport preserved" }
updateState(
FirmwareUpdateState.Error(
UiText.Resource(Res.string.firmware_update_ota_unsupported_reason, preflight.message),
),
)
preflight
}
is OtaPreflightResult.LegacyFallback -> {
Logger.w {
"ESP32 OTA: No firmware confirmation within ${environment.otaPreflightTimeoutMs}ms; " +
"using legacy OTA reconnect"
}
releaseMeshTransportForOta()
preflight
}
}
private suspend fun releaseMeshTransportForOta() {
disconnectMeshService()
// Give BLE stack time to fully release the GATT connection.
environment.delay(environment.gattReleaseDelayMs)
}
/**
* Race the firmware's OTA-entry ClientNotification against [timeoutMs]. The [baselineMessage] is captured by the
* caller BEFORE the OTA trigger is sent, so a stale notification already held in the StateFlow doesn't get mistaken
* for the response.
*/
private suspend fun awaitOtaConfirmation(
timeoutMs: Long,
rebootMode: Int,
baselineMessage: String?,
): OtaPreflightResult {
val modeName = otaModeName(rebootMode)
val matched =
withTimeoutOrNull(timeoutMs) {
radioController.clientNotification.first { cn ->
cn != null && cn.message != baselineMessage && cn.isOtaStatusNotification()
}
}
matched ?: return OtaPreflightResult.LegacyFallback
radioController.clearClientNotification()
return when {
// Defense-in-depth: require both the canonical "Rebooting to" prefix AND the requested mode name in the
// message. Firmware is consistent today, but this guards against a future firmware bug that acks the wrong
// mode — a wrong-mode confirmation would route the host to a transport the device did not enter.
matched.message.startsWith(OTA_CONFIRM_PREFIX, ignoreCase = true) &&
matched.message.contains(modeName, ignoreCase = true) -> {
Logger.i { "ESP32 OTA: Firmware confirmed $modeName OTA entry: ${matched.message}" }
OtaPreflightResult.Confirmed
}
else -> OtaPreflightResult.Rejected(matched.message)
}
}
private fun otaModeName(rebootMode: Int): String = when (rebootMode) {
REBOOT_MODE_BLE -> "BLE"
REBOOT_MODE_WIFI -> "WiFi"
else -> rebootMode.toString()
}
}

View File

@@ -0,0 +1,106 @@
/*
* Copyright (c) 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
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* 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.firmware.ota
import co.touchlab.kermit.Logger
import io.ktor.network.selector.SelectorManager
import io.ktor.network.sockets.InetSocketAddress
import io.ktor.network.sockets.SocketAddress
import io.ktor.network.sockets.aSocket
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.io.readByteArray
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.safeCatching
/**
* Listens for the ESP32 OTA loader's UDP discovery broadcast so the host can learn the device's post-reboot IP.
*
* After rebooting into OTA mode the loader emits a UDP broadcast to `255.255.255.255:[port]` every ~1 second. DHCP may
* assign a different IP in OTA mode than in normal operation, so connecting to the previously-known IP can fail; this
* discovery resolves the actual address.
*/
internal object WifiOtaDiscovery {
/**
* Listens for the OTA loader's UDP discovery broadcast on [port] and returns the sender's IP address. Returns
* `null` on timeout, bind failure, or any other receive error — callers fall back to the original IP.
*/
suspend fun discoverOtaDevice(
port: Int = WifiOtaTransport.DEFAULT_PORT,
timeoutMs: Long = DEFAULT_TIMEOUT_MS,
): String? = withContext(ioDispatcher) {
// NOTE: No MulticastLock acquired — that is an Android-only API and this is commonMain. All-ones
// limited broadcasts (255.255.255.255) are typically delivered without it; if a specific device filters
// them, add an expect/actual multicast-lock wrapper and acquire it for the duration of [receive].
safeCatching {
// ponytail: var accumulator + loop-on-condition. withTimeoutOrNull's block type comes from its
// terminal expression; an infinite `while(true){...}` whose body ends in Logger.d (Unit) makes
// the whole block Unit, which clashes with any explicit <T> param. Accumulating into `discovered`
// gives the block a concrete String? terminal and lets inference handle the rest.
withTimeoutOrNull(timeoutMs) {
val selector = SelectorManager(ioDispatcher)
var discovered: String? = null
try {
val socket = aSocket(selector).udp().bind(InetSocketAddress("0.0.0.0", port))
try {
Logger.i { "WiFi OTA: Listening for OTA device discovery broadcast on port $port" }
while (discovered == null) {
val datagram = socket.receive()
val candidate = senderHost(datagram.address)
val payload = datagram.packet.readByteArray()
if (candidate != null && isOtaDiscoveryBeacon(payload)) {
Logger.i { "WiFi OTA: Discovered OTA device broadcast" }
discovered = candidate
} else {
Logger.d { "WiFi OTA: Ignoring non-Meshtastic OTA discovery datagram" }
}
}
} finally {
socket.close()
}
} finally {
selector.close()
}
discovered
}
}
.getOrNull()
}
@Suppress("ReturnCount") // early-out paths for different address shapes
internal fun senderHost(address: SocketAddress): String? {
val sock = address as? InetSocketAddress ?: return null
// InetSocketAddress.hostname may trigger a reverse-DNS lookup; use only the raw IPv4 bytes returned by
// resolveAddress() (4 bytes for IPv4 — the only shape ESP32 OTA loader broadcasts).
val bytes = sock.resolveAddress()
if (bytes != null && bytes.size == IPV4_ADDRESS_BYTES) {
return bytes.joinToString(".") { (it.toInt() and BYTE_MASK).toString() }
}
return null
}
internal fun isOtaDiscoveryBeacon(payload: ByteArray): Boolean {
val message = payload.decodeToString().trim()
return OTA_DISCOVERY_PATTERN.matches(message)
}
// esp32-unified-ota broadcasts "<deviceName> <version>", where deviceName is "Meshtastic_" plus 4 MAC hex digits.
private val OTA_DISCOVERY_PATTERN = Regex("""^Meshtastic_[0-9A-Fa-f]{4}\s+\S{1,32}$""")
private const val IPV4_ADDRESS_BYTES = 4
private const val BYTE_MASK = 0xFF
private const val DEFAULT_TIMEOUT_MS = 3_000L
}

View File

@@ -28,6 +28,7 @@ import io.ktor.utils.io.ByteWriteChannel
import io.ktor.utils.io.readLine
import io.ktor.utils.io.writeFully
import io.ktor.utils.io.writeStringUtf8
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
@@ -62,8 +63,15 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In
selectorManager = selector
val tcpSocket =
withTimeout(CONNECTION_TIMEOUT_MS) {
aSocket(selector).tcp().connect(InetSocketAddress(deviceIpAddress, port))
try {
withTimeout(CONNECTION_TIMEOUT_MS) {
aSocket(selector).tcp().connect(InetSocketAddress(deviceIpAddress, port))
}
} catch (e: TimeoutCancellationException) {
throw OtaProtocolException.ConnectionFailed(
"TCP connect to $deviceIpAddress:$port timed out after ${CONNECTION_TIMEOUT_MS}ms",
e,
)
}
socket = tcpSocket
@@ -191,11 +199,15 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In
wc.flush()
}
private suspend fun readResponse(timeoutMs: Long = COMMAND_TIMEOUT_MS): String = withTimeout(timeoutMs) {
val rc = readChannel ?: throw OtaProtocolException.ConnectionFailed("Not connected")
val response = rc.readLine() ?: throw OtaProtocolException.ConnectionFailed("Connection closed")
Logger.d { "WiFi OTA: Received response: $response" }
response
private suspend fun readResponse(timeoutMs: Long = COMMAND_TIMEOUT_MS): String = try {
withTimeout(timeoutMs) {
val rc = readChannel ?: throw OtaProtocolException.ConnectionFailed("Not connected")
val response = rc.readLine() ?: throw OtaProtocolException.ConnectionFailed("Connection closed")
Logger.d { "WiFi OTA: Received response: $response" }
response
}
} catch (e: TimeoutCancellationException) {
throw OtaProtocolException.ConnectionFailed("Timed out waiting for OTA response after ${timeoutMs}ms", e)
}
companion object {

View File

@@ -26,9 +26,11 @@ import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.feature.firmware.ota.DefaultEsp32OtaUpdateEnvironment
import org.meshtastic.feature.firmware.ota.Esp32OtaUpdateHandler
import org.meshtastic.feature.firmware.ota.dfu.SecureDfuHandler
import kotlin.test.Test
@@ -91,6 +93,8 @@ class DefaultFirmwareUpdateManagerTest {
firmwareFileHandler = fileHandler,
radioController = radioController,
nodeRepository = nodeRepository,
firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository(),
environment = DefaultEsp32OtaUpdateEnvironment(),
bleScanner = bleScanner,
bleConnectionFactory = bleConnectionFactory,
dispatchers = dispatchers,

View File

@@ -0,0 +1,543 @@
/*
* Copyright (c) 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
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* 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.firmware.ota
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.TestDataFactory
import org.meshtastic.feature.firmware.FirmwareArtifact
import org.meshtastic.feature.firmware.FirmwareFileHandler
import org.meshtastic.feature.firmware.FirmwareRetriever
import org.meshtastic.feature.firmware.FirmwareUpdateState
import org.meshtastic.proto.ClientNotification
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Tests for [Esp32OtaUpdateHandler]'s OTA preflight gate — the bounded wait for firmware confirmation between
* `triggerRebootOta` and tearing down the mesh transport.
*
* Each test drives `startUpdate` end-to-end just far enough to observe the preflight outcome:
* - Confirmed → mesh disconnect (`setDeviceAddress("n")`) is invoked, then the handler proceeds into the BLE transport
* phase, which fails fast on the mocked BLE deps. We bound the call with `withTimeoutOrNull` so the post-preflight
* BLE-connect retry loop is cancelled before it costs real wall-clock time.
* - Rejected → mesh transport is preserved (no `setDeviceAddress`) and an `Error` state is emitted; the handler returns
* cleanly with no exception to chase.
* - Silent firmware → legacy fallback disconnects the mesh transport and proceeds toward OTA connection attempts.
*
* The firmware response is simulated by mutating [FakeRadioController.clientNotification] from a sibling coroutine
* scheduled to fire after `triggerRebootOta`. Because baseline is captured BEFORE the trigger (see `performUpdate`), a
* value written afterward is recognized as the response, not the baseline.
*/
class Esp32OtaUpdateHandlerTest {
private val release = FirmwareRelease(id = "v1.0", title = "test")
private val hardware =
DeviceHardware(hwModelSlug = "HELTEC_V3", platformioTarget = "heltec-v3", architecture = "esp32-s3")
private val firmwareUri = CommonUri.parse("file:///tmp/firmware.bin")
private val dispatchers =
CoroutineDispatchers(
io = Dispatchers.Unconfined,
main = Dispatchers.Unconfined,
default = Dispatchers.Unconfined,
)
private fun makeHandler(
radioController: FakeRadioController,
nodeRepository: FakeNodeRepository,
fileHandler: FirmwareFileHandler,
firmwareUpdateStatusRepository: FirmwareUpdateStatusRepository,
environment: TestEsp32OtaUpdateEnvironment,
): Esp32OtaUpdateHandler = Esp32OtaUpdateHandler(
firmwareRetriever = FirmwareRetriever(fileHandler),
firmwareFileHandler = fileHandler,
radioController = radioController,
nodeRepository = nodeRepository,
firmwareUpdateStatusRepository = firmwareUpdateStatusRepository,
environment = environment,
bleScanner = mock(MockMode.autofill),
bleConnectionFactory = mock(MockMode.autofill),
dispatchers = dispatchers,
)
private fun newFileHandler(): FirmwareFileHandler = mock(MockMode.autofill) {
everySuspend { readBytes(any()) } returns byteArrayOf(1, 2, 3, 4)
everySuspend { importFromUri(any()) } returns
FirmwareArtifact(uri = firmwareUri, fileName = "firmware.bin", isTemporary = false)
}
private fun newNodeRepository(): FakeNodeRepository = FakeNodeRepository().apply {
setMyNodeInfo(TestDataFactory.createMyNodeInfo(myNodeNum = 1, firmwareVersion = "1.0"))
}
/** Schedule [block] on a sibling Default dispatcher; cancelled at the end of the test. */
private fun runSideEffect(block: suspend CoroutineScope.() -> Unit): Job =
CoroutineScope(Dispatchers.Default).launch { block() }
/**
* Shared fixture builder for preflight tests. Schedules a sibling coroutine that emits [confirmationMessage] as a
* firmware ClientNotification after [CONFIRMATION_DELAY_MS]; pass `confirmationMessage = null` to skip the emitter
* entirely and exercise the legacy fallback path.
*/
private fun runPreflightTest(
target: String = BLE_TARGET,
confirmationMessage: String? = BLE_CONFIRMATION,
configure: TestEsp32OtaUpdateEnvironment.() -> Unit = {},
runUpdate: suspend PreflightFixture.() -> Unit = {
handler.startUpdate(release, hardware, target, states::add, firmwareUri)
},
assertions: PreflightFixture.() -> Unit,
) = runBlocking {
val radioController = FakeRadioController()
val nodeRepository = newNodeRepository()
val firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository()
val environment = TestEsp32OtaUpdateEnvironment().apply(configure)
val handler =
makeHandler(
radioController = radioController,
nodeRepository = nodeRepository,
fileHandler = newFileHandler(),
firmwareUpdateStatusRepository = firmwareUpdateStatusRepository,
environment = environment,
)
val states = mutableListOf<FirmwareUpdateState>()
val fixture = PreflightFixture(handler = handler, radioController = radioController, states = states)
val emitter =
confirmationMessage?.let { msg ->
runSideEffect {
delay(CONFIRMATION_DELAY_MS)
radioController.setClientNotification(ClientNotification(message = msg))
}
}
try {
fixture.runUpdate()
} finally {
emitter?.cancel()
}
fixture.assertions()
assertFalse(
firmwareUpdateStatusRepository.status.value.isOtaUpdateActive,
"Firmware update status must reset after the handler finishes or is cancelled",
)
}
// ── Confirmed ──────────────────────────────────────────────────────────
@Test
fun `Confirmed preflight releases the mesh transport`() = runPreflightTest(
target = BLE_TARGET,
confirmationMessage = BLE_CONFIRMATION,
runUpdate = {
// Bound the call: we only need to observe the preflight side effect. The post-preflight BLE transport
// phase would otherwise burn ~30 s of real wall-clock time on retry/delay loops with mocked BLE deps.
withTimeoutOrNull(3000L) {
handler.startUpdate(release, hardware, BLE_TARGET, states::add, firmwareUri)
}
},
) {
assertEquals("n", radioController.lastSetDeviceAddress, "Confirmed preflight must disconnect mesh service")
}
@Test
fun `Confirmed BLE preflight retries with fresh transport after connection failure`() {
val events = mutableListOf<String>()
val transports =
ArrayDeque<FakeOtaTransport>().apply {
add(
FakeOtaTransport(
name = "first",
events = events,
connectResult = Result.failure(OtaProtocolException.ConnectionFailed("OTA service missing")),
),
)
add(FakeOtaTransport(name = "second", events = events))
}
runPreflightTest(
target = BLE_TARGET,
confirmationMessage = BLE_CONFIRMATION,
configure = {
gattReleaseDelayMs = 0L
otaTransportRetryDelayMs = 0L
bleTransportFactory = { transports.removeFirst() }
},
) {
assertEquals("n", radioController.lastSetDeviceAddress, "Confirmed preflight must disconnect mesh service")
assertTrue(events.contains("close:first"), "Failed transport must be closed before retry")
assertTrue(events.contains("connect:second"), "Retry must create and connect a fresh transport")
assertTrue(events.indexOf("close:first") < events.indexOf("connect:second"))
assertTrue(events.contains("close:second"), "Successful transport must be closed after transfer")
assertTrue(states.any { it is FirmwareUpdateState.Success }, "Later fresh transport should complete update")
}
}
@Test
fun `Confirmed WiFi preflight waits for OTA service readiness before connecting`() {
val events = mutableListOf<String>()
runPreflightTest(
target = WIFI_TARGET,
confirmationMessage = WIFI_CONFIRMATION,
configure = {
gattReleaseDelayMs = 0L
wifiOtaReadinessDelayMs = TEST_WIFI_READINESS_DELAY_MS
delayBlock = { delayMs -> events += "delay:$delayMs" }
wifiTransportFactory = { FakeOtaTransport(name = "wifi", events = events) }
},
) {
assertTrue(
events.contains("delay:$TEST_WIFI_READINESS_DELAY_MS"),
"WiFi post-confirm readiness delay must run",
)
assertTrue(events.contains("connect:wifi"), "WiFi transport must connect after readiness delay")
assertTrue(events.indexOf("delay:$TEST_WIFI_READINESS_DELAY_MS") < events.indexOf("connect:wifi"))
assertTrue(
states.any { it is FirmwareUpdateState.Success },
"WiFi transport should connect after readiness delay",
)
}
}
@Test
fun `Confirmed WiFi preflight uses UDP discovered address before connecting`() {
val events = mutableListOf<String>()
runPreflightTest(
target = WIFI_TARGET,
confirmationMessage = WIFI_CONFIRMATION,
configure = {
gattReleaseDelayMs = 0L
wifiOtaReadinessDelayMs = TEST_WIFI_READINESS_DELAY_MS
delayBlock = { delayMs -> events += "delay:$delayMs" }
wifiDiscoveryEnabled = true
discoverWifiOtaDeviceBlock = {
events += "discover"
DISCOVERED_WIFI_TARGET
}
wifiTransportFactory = { target ->
events += "factory:$target"
FakeOtaTransport(name = target, events = events)
}
},
) {
assertTrue(events.contains("discover"), "WiFi OTA should listen for the loader's DHCP address")
assertTrue(
events.contains("factory:$DISCOVERED_WIFI_TARGET"),
"Discovered DHCP address must replace the pre-reboot address",
)
assertTrue(
events.contains("connect:$DISCOVERED_WIFI_TARGET"),
"WiFi transport must connect to the discovered address",
)
assertTrue(
events.none { it == "factory:$WIFI_TARGET" || it == "connect:$WIFI_TARGET" },
"Configured pre-reboot address should not be used after discovery succeeds",
)
assertTrue(
events.none { it == "delay:$TEST_WIFI_READINESS_DELAY_MS" },
"Discovery timeout window counts as the post-confirm readiness wait",
)
assertTrue(states.any { it is FirmwareUpdateState.Success })
}
}
@Test
fun `Confirmed WiFi preflight falls back to configured address when UDP discovery misses`() {
val events = mutableListOf<String>()
runPreflightTest(
target = WIFI_TARGET,
confirmationMessage = WIFI_CONFIRMATION,
configure = {
gattReleaseDelayMs = 0L
wifiOtaReadinessDelayMs = TEST_WIFI_READINESS_DELAY_MS
delayBlock = { delayMs -> events += "delay:$delayMs" }
wifiDiscoveryEnabled = true
discoverWifiOtaDeviceBlock = {
events += "discover"
null
}
wifiTransportFactory = { target ->
events += "factory:$target"
FakeOtaTransport(name = target, events = events)
}
},
) {
assertTrue(events.contains("discover"), "WiFi OTA should attempt UDP discovery before fixed-IP fallback")
assertTrue(events.contains("factory:$WIFI_TARGET"), "Missing discovery must fall back to configured target")
assertTrue(events.contains("connect:$WIFI_TARGET"), "Fallback transport must still connect")
assertTrue(
events.contains("delay:$TEST_WIFI_READINESS_DELAY_MS"),
"Discovery miss must still apply the post-confirm readiness margin",
)
assertTrue(
events.indexOf("delay:$TEST_WIFI_READINESS_DELAY_MS") < events.indexOf("connect:$WIFI_TARGET"),
"Readiness delay must run before connecting",
)
assertTrue(states.any { it is FirmwareUpdateState.Success })
}
}
@Test
fun `Confirmed WiFi preflight retries with fresh transport after connection failure`() {
val events = mutableListOf<String>()
val transports =
ArrayDeque<FakeOtaTransport>().apply {
add(
FakeOtaTransport(
name = "wifi-first",
events = events,
connectResult = Result.failure(OtaProtocolException.ConnectionFailed("Connection refused")),
),
)
add(FakeOtaTransport(name = "wifi-second", events = events))
}
runPreflightTest(
target = WIFI_TARGET,
confirmationMessage = WIFI_CONFIRMATION,
configure = {
gattReleaseDelayMs = 0L
wifiOtaReadinessDelayMs = 0L
otaTransportRetryDelayMs = 0L
wifiTransportFactory = { transports.removeFirst() }
},
) {
assertEquals("n", radioController.lastSetDeviceAddress, "Confirmed preflight must disconnect mesh service")
assertTrue(events.contains("close:wifi-first"), "Failed WiFi transport must be closed before retry")
assertTrue(events.contains("connect:wifi-second"), "WiFi retry must create and connect a fresh transport")
assertTrue(events.indexOf("close:wifi-first") < events.indexOf("connect:wifi-second"))
assertTrue(events.contains("close:wifi-second"), "Successful WiFi transport must be closed after transfer")
assertTrue(
states.any { it is FirmwareUpdateState.Success },
"Later fresh WiFi transport should complete update",
)
}
}
@Test
fun `Confirmed WiFi preflight closes failed transports and surfaces connection error after retries`() {
val events = mutableListOf<String>()
var attempt = 0
runPreflightTest(
target = WIFI_TARGET,
confirmationMessage = WIFI_CONFIRMATION,
configure = {
gattReleaseDelayMs = 0L
wifiOtaReadinessDelayMs = 0L
otaTransportRetryDelayMs = 0L
wifiTransportFactory = {
attempt += 1
FakeOtaTransport(
name = "wifi-$attempt",
events = events,
connectResult = Result.failure(OtaProtocolException.ConnectionFailed("Connection refused")),
)
}
},
) {
assertEquals("n", radioController.lastSetDeviceAddress, "Confirmed preflight must disconnect mesh service")
assertEquals(WIFI_CONNECT_ATTEMPTS, attempt, "WiFi should use the full post-confirm retry budget")
repeat(WIFI_CONNECT_ATTEMPTS) { index ->
val name = "wifi-${index + 1}"
assertTrue(events.contains("connect:$name"), "Attempt ${index + 1} must connect a fresh transport")
assertTrue(events.contains("close:$name"), "Attempt ${index + 1} must close its failed transport")
assertTrue(events.indexOf("connect:$name") < events.indexOf("close:$name"))
}
assertTrue(events.none { it.startsWith("start:") || it.startsWith("stream:") })
assertIs<FirmwareUpdateState.Error>(states.lastOrNull())
Unit
}
}
@Test
fun `Confirmed BLE preflight closes failed transports and surfaces connection error after retries`() {
val events = mutableListOf<String>()
var attempt = 0
runPreflightTest(
target = BLE_TARGET,
confirmationMessage = BLE_CONFIRMATION,
configure = {
gattReleaseDelayMs = 0L
otaTransportRetryDelayMs = 0L
bleTransportFactory = {
attempt += 1
FakeOtaTransport(
name = "ble-$attempt",
events = events,
connectResult = Result.failure(OtaProtocolException.ConnectionFailed("GATT unreachable")),
)
}
},
) {
assertEquals("n", radioController.lastSetDeviceAddress, "Confirmed preflight must disconnect mesh service")
assertEquals(BLE_CONNECT_ATTEMPTS, attempt, "BLE should use the full post-confirm retry budget")
repeat(BLE_CONNECT_ATTEMPTS) { index ->
val name = "ble-${index + 1}"
assertTrue(events.contains("connect:$name"), "Attempt ${index + 1} must connect a fresh transport")
assertTrue(events.contains("close:$name"), "Attempt ${index + 1} must close its failed transport")
assertTrue(events.indexOf("connect:$name") < events.indexOf("close:$name"))
}
assertTrue(events.none { it.startsWith("start:") || it.startsWith("stream:") })
assertIs<FirmwareUpdateState.Error>(states.lastOrNull())
Unit
}
}
// ── Rejected ───────────────────────────────────────────────────────────
@Test
fun `Rejected preflight preserves mesh transport and surfaces Error`() = runPreflightTest(
// OTA-keyed message that is NOT the canonical "Rebooting to <mode> OTA" success prefix → Rejected.
confirmationMessage = "Cannot start OTA: OTA Loader partition not found.",
) {
assertNull(radioController.lastSetDeviceAddress, "Rejected preflight must NOT disconnect mesh service")
assertTrue(states.any { it is FirmwareUpdateState.Error }, "Rejected preflight must emit Error state")
}
// ── Legacy fallback ────────────────────────────────────────────────────
@Test
fun `Silent preflight falls back to legacy OTA reconnect path`() = runPreflightTest(
// No emitter (confirmationMessage = null): older firmware may be silent, so the overridden preflight
// timeout
// should resolve quickly into the same disconnect/reconnect path the app used before preflight
// confirmation.
confirmationMessage = null,
configure = {
otaPreflightTimeoutMs = 10L
gattReleaseDelayMs = 0L
},
runUpdate = {
withTimeoutOrNull(3000L) {
handler.startUpdate(release, hardware, BLE_TARGET, states::add, firmwareUri)
}
},
) {
assertEquals(
"n",
radioController.lastSetDeviceAddress,
"Silent preflight must still disconnect mesh service",
)
}
private companion object {
// Valid BLE MAC target so startUpdate routes through the BLE OTA preflight path.
const val BLE_TARGET = "AA:BB:CC:DD:EE:FF"
const val WIFI_TARGET = "192.168.1.33"
const val DISCOVERED_WIFI_TARGET = "192.168.1.44"
const val BLE_CONFIRMATION = "Rebooting to BLE OTA"
const val WIFI_CONFIRMATION = "Rebooting to WiFi OTA"
const val CONFIRMATION_DELAY_MS = 50L
const val BLE_CONNECT_ATTEMPTS = 5
const val WIFI_CONNECT_ATTEMPTS = 10
const val TEST_WIFI_READINESS_DELAY_MS = 1234L
}
}
private data class PreflightFixture(
val handler: Esp32OtaUpdateHandler,
val radioController: FakeRadioController,
val states: MutableList<FirmwareUpdateState>,
)
private class TestEsp32OtaUpdateEnvironment : Esp32OtaUpdateEnvironment {
override var otaPreflightTimeoutMs: Long = 5_000L
override var otaTransportRetryDelayMs: Long = 2_000L
override var wifiOtaReadinessDelayMs: Long = 8_000L
override var gattReleaseDelayMs: Long = 1_000L
override var wifiDiscoveryEnabled: Boolean = false
// Fully qualified: an unqualified `delay(it)` would resolve to this class's own override (member functions win
// over imported top-level functions), producing infinite recursion through delayBlock → StackOverflowError.
var delayBlock: suspend (Long) -> Unit = { kotlinx.coroutines.delay(it) }
var bleTransportFactory: ((String) -> UnifiedOtaProtocol)? = null
var wifiTransportFactory: ((String) -> UnifiedOtaProtocol)? = null
var discoverWifiOtaDeviceBlock: suspend () -> String? = { null }
override suspend fun delay(milliseconds: Long) {
delayBlock(milliseconds)
}
override fun createBleTransport(
bleScanner: org.meshtastic.core.ble.BleScanner,
bleConnectionFactory: org.meshtastic.core.ble.BleConnectionFactory,
address: String,
dispatcher: CoroutineDispatcher,
): UnifiedOtaProtocol =
bleTransportFactory?.invoke(address) ?: BleOtaTransport(bleScanner, bleConnectionFactory, address, dispatcher)
override fun createWifiTransport(deviceIp: String): UnifiedOtaProtocol =
wifiTransportFactory?.invoke(deviceIp) ?: WifiOtaTransport(deviceIp)
override suspend fun discoverWifiOtaDevice(): String? = discoverWifiOtaDeviceBlock.invoke()
}
private class FakeOtaTransport(
private val name: String,
private val events: MutableList<String>,
private val connectResult: Result<Unit> = Result.success(Unit),
) : UnifiedOtaProtocol {
override suspend fun connect(): Result<Unit> {
events += "connect:$name"
return connectResult
}
override suspend fun startOta(
sizeBytes: Long,
sha256Hash: String,
onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit,
): Result<Unit> {
events += "start:$name"
return Result.success(Unit)
}
override suspend fun streamFirmware(
data: ByteArray,
chunkSize: Int,
onProgress: suspend (Float) -> Unit,
): Result<Unit> {
events += "stream:$name"
onProgress(1f)
return Result.success(Unit)
}
override suspend fun close() {
events += "close:$name"
}
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright (c) 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
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* 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.firmware.ota
import io.ktor.network.sockets.InetSocketAddress
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class WifiOtaDiscoveryTest {
@Test
fun `senderHost extracts host from UDP sender address`() {
val address = InetSocketAddress("192.168.1.44", WifiOtaTransport.DEFAULT_PORT)
assertEquals("192.168.1.44", WifiOtaDiscovery.senderHost(address))
}
@Test
fun `Meshtastic OTA discovery beacon is identified`() {
assertTrue(WifiOtaDiscovery.isOtaDiscoveryBeacon("Meshtastic_ABCD 1.2.3".encodeToByteArray()))
assertTrue(WifiOtaDiscovery.isOtaDiscoveryBeacon("Meshtastic_01af v1.2-5-g8a2b3c".encodeToByteArray()))
}
@Test
fun `unrelated port 3232 datagrams are ignored`() {
assertFalse(WifiOtaDiscovery.isOtaDiscoveryBeacon(ByteArray(0)))
assertFalse(WifiOtaDiscovery.isOtaDiscoveryBeacon("OK".encodeToByteArray()))
assertFalse(WifiOtaDiscovery.isOtaDiscoveryBeacon("0 3232 1024 abcdef".encodeToByteArray()))
assertFalse(WifiOtaDiscovery.isOtaDiscoveryBeacon("Meshtastic_OTA 1.2.3".encodeToByteArray()))
assertFalse(WifiOtaDiscovery.isOtaDiscoveryBeacon("Meshtastic_ABCD".encodeToByteArray()))
}
}