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())) + } +}