From c2a8e67aee4db8f77b7dc6924dcebfbdfba1bf5e Mon Sep 17 00:00:00 2001
From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com>
Date: Wed, 8 Jul 2026 22:07:25 -0500
Subject: [PATCH] fix(firmware): Retry ESP32 OTA service connections cleanly
(#6081)
---
.skills/compose-ui/strings-index.txt | 2 +
.../manager/FromRadioPacketHandlerImpl.kt | 8 +
.../manager/FromRadioPacketHandlerImplTest.kt | 23 +
.../meshtastic/core/model/util/Extensions.kt | 11 +
.../FirmwareUpdateStatusRepository.kt | 45 ++
.../repository/di/CoreRepositoryModule.kt | 4 +
.../composeResources/values/strings.xml | 4 +-
.../core/testing/FakeRadioController.kt | 4 +
.../core/ui/viewmodel/UIViewModel.kt | 11 +
.../viewmodel/UIViewModelImportSummaryTest.kt | 21 +
.../firmware/ota/Esp32OtaUpdateHandler.kt | 542 +++++++++++++----
.../feature/firmware/ota/WifiOtaDiscovery.kt | 106 ++++
.../feature/firmware/ota/WifiOtaTransport.kt | 26 +-
.../DefaultFirmwareUpdateManagerTest.kt | 4 +
.../firmware/ota/Esp32OtaUpdateHandlerTest.kt | 543 ++++++++++++++++++
.../firmware/ota/WifiOtaDiscoveryTest.kt | 48 ++
16 files changed, 1297 insertions(+), 105 deletions(-)
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FirmwareUpdateStatusRepository.kt
create mode 100644 feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaDiscovery.kt
create mode 100644 feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandlerTest.kt
create mode 100644 feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaDiscoveryTest.kt
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index cb38fcc83..fdd16ebf5 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -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
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
index f23bf2f67..4a13b7b12 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
@@ -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
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt
index 243d966a0..22cb8c04a 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt
@@ -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())
+ }
}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt
index 30dd23b7c..0c257291d 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt
@@ -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)
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FirmwareUpdateStatusRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FirmwareUpdateStatusRepository.kt
new file mode 100644
index 000000000..16b3d5c84
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FirmwareUpdateStatusRepository.kt
@@ -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 .
+ */
+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 = _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()
+ }
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/di/CoreRepositoryModule.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/di/CoreRepositoryModule.kt
index 262377c75..c5b19a750 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/di/CoreRepositoryModule.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/di/CoreRepositoryModule.kt
@@ -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,
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 3d56bbffa..5918d4c36 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -658,7 +658,8 @@
Node user information is missing.
Could not find firmware for %1$s in release.
OTA update failed: %1$s
- 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.
+ This device\'s OTA loader rejected the requested update method: %1$s
+ 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.
Rebooting to DFU...
Release Notes
%1$s requires a target-matching .bin file.
@@ -667,6 +668,7 @@
Could not retrieve firmware file.
Retry
Please save the .uf2 file to your device's DFU drive.
+ Searching for OTA device on the network...
Select Local File
Updates are slow on this device's bootloader. Flashing the OTAFIX bootloader enables much faster BLE updates.
Source: Local File
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt
index cc8c2437a..70913d953 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt
@@ -212,4 +212,8 @@ class FakeRadioController :
fun setConnectionState(state: ConnectionState) {
_connectionState.value = state
}
+
+ fun setClientNotification(notification: ClientNotification?) {
+ _clientNotification.value = notification
+ }
}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
index 6fd72c5b3..ba29bc674 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt
@@ -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,
diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModelImportSummaryTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModelImportSummaryTest.kt
index 5fded3d55..f2d90e78e 100644
--- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModelImportSummaryTest.kt
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModelImportSummaryTest.kt
@@ -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())
+ }
}
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt
index 84281b060..2cb416a81 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt
@@ -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 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 ", 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 {
+ 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
+ * 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()
+ }
}
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaDiscovery.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaDiscovery.kt
new file mode 100644
index 000000000..a7fcba4f7
--- /dev/null
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaDiscovery.kt
@@ -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 .
+ */
+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 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 " ", 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
+}
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt
index 12e2429c2..ba5240f22 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt
@@ -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 {
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt
index 84b5cfa41..e224c3fff 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt
@@ -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,
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandlerTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandlerTest.kt
new file mode 100644
index 000000000..6a57ba4f2
--- /dev/null
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandlerTest.kt
@@ -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 .
+ */
+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()
+ 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()
+ val transports =
+ ArrayDeque().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()
+ 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()
+ 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()
+ 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()
+ val transports =
+ ArrayDeque().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()
+ 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(states.lastOrNull())
+ Unit
+ }
+ }
+
+ @Test
+ fun `Confirmed BLE preflight closes failed transports and surfaces connection error after retries`() {
+ val events = mutableListOf()
+ 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(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 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,
+)
+
+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,
+ private val connectResult: Result = Result.success(Unit),
+) : UnifiedOtaProtocol {
+ override suspend fun connect(): Result {
+ events += "connect:$name"
+ return connectResult
+ }
+
+ override suspend fun startOta(
+ sizeBytes: Long,
+ sha256Hash: String,
+ onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit,
+ ): Result {
+ events += "start:$name"
+ return Result.success(Unit)
+ }
+
+ override suspend fun streamFirmware(
+ data: ByteArray,
+ chunkSize: Int,
+ onProgress: suspend (Float) -> Unit,
+ ): Result {
+ events += "stream:$name"
+ onProgress(1f)
+ return Result.success(Unit)
+ }
+
+ override suspend fun close() {
+ events += "close:$name"
+ }
+}
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaDiscoveryTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaDiscoveryTest.kt
new file mode 100644
index 000000000..2b4ae20eb
--- /dev/null
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaDiscoveryTest.kt
@@ -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 .
+ */
+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()))
+ }
+}