mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-09 12:55:45 -04:00
fix(firmware): Retry ESP32 OTA service connections cleanly (#6081)
This commit is contained in:
2
.skills/compose-ui/strings-index.txt
generated
2
.skills/compose-ui/strings-index.txt
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
data class FirmwareUpdateStatus(val isOtaUpdateActive: Boolean = false, val isAwaitingOtaStatus: Boolean = false)
|
||||
|
||||
class FirmwareUpdateStatusRepository {
|
||||
private val _status = MutableStateFlow(FirmwareUpdateStatus())
|
||||
val status: StateFlow<FirmwareUpdateStatus> = _status.asStateFlow()
|
||||
|
||||
fun beginOtaUpdate() {
|
||||
_status.value = FirmwareUpdateStatus(isOtaUpdateActive = true)
|
||||
}
|
||||
|
||||
fun beginOtaPreflight() {
|
||||
_status.value = FirmwareUpdateStatus(isOtaUpdateActive = true, isAwaitingOtaStatus = true)
|
||||
}
|
||||
|
||||
fun finishOtaPreflight() {
|
||||
_status.update { it.copy(isAwaitingOtaStatus = false) }
|
||||
}
|
||||
|
||||
fun endOtaUpdate() {
|
||||
_status.value = FirmwareUpdateStatus()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -658,7 +658,8 @@
|
||||
<string name="firmware_update_node_info_missing">Node user information is missing.</string>
|
||||
<string name="firmware_update_not_found_in_release">Could not find firmware for %1$s in release.</string>
|
||||
<string name="firmware_update_ota_failed">OTA update failed: %1$s</string>
|
||||
<string name="firmware_update_rak4631_bootloader_hint">For RAK WisBlock RAK4631, use the vendor's serial DFU tool (for example, adafruit-nrfutil dfu serial with the provided bootloader .zip file). Copying the .uf2 file alone will not update the bootloader.</string>
|
||||
<string name="firmware_update_ota_unsupported_reason">This device\'s OTA loader rejected the requested update method: %1$s</string>
|
||||
<string name="firmware_update_rak4631_bootloader_hint">For RAK WisBlock RAK4631, use the vendor\'s serial DFU tool (for example, adafruit-nrfutil dfu serial with the provided bootloader .zip file). Copying the .uf2 file alone will not update the bootloader.</string>
|
||||
<string name="firmware_update_rebooting">Rebooting to DFU...</string>
|
||||
<string name="firmware_update_release_notes">Release Notes</string>
|
||||
<string name="firmware_update_requires_bin">%1$s requires a target-matching .bin file.</string>
|
||||
@@ -667,6 +668,7 @@
|
||||
<string name="firmware_update_retrieval_failed">Could not retrieve firmware file.</string>
|
||||
<string name="firmware_update_retry">Retry</string>
|
||||
<string name="firmware_update_save_dfu_file">Please save the .uf2 file to your device's DFU drive.</string>
|
||||
<string name="firmware_update_searching_device">Searching for OTA device on the network...</string>
|
||||
<string name="firmware_update_select_file">Select Local File</string>
|
||||
<string name="firmware_update_slow_bootloader_hint">Updates are slow on this device's bootloader. Flashing the OTAFIX bootloader enables much faster BLE updates.</string>
|
||||
<string name="firmware_update_source_local">Source: Local File</string>
|
||||
|
||||
@@ -212,4 +212,8 @@ class FakeRadioController :
|
||||
fun setConnectionState(state: ConnectionState) {
|
||||
_connectionState.value = state
|
||||
}
|
||||
|
||||
fun setClientNotification(notification: ClientNotification?) {
|
||||
_clientNotification.value = notification
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,11 @@ package org.meshtastic.feature.firmware.ota
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.ble.BleConnectionFactory
|
||||
import org.meshtastic.core.ble.BleScanner
|
||||
@@ -28,6 +31,8 @@ import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.model.util.isOtaStatusNotification
|
||||
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.RadioController
|
||||
import org.meshtastic.core.resources.Res
|
||||
@@ -39,6 +44,8 @@ import org.meshtastic.core.resources.firmware_update_extracting
|
||||
import org.meshtastic.core.resources.firmware_update_hash_rejected
|
||||
import org.meshtastic.core.resources.firmware_update_not_found_in_release
|
||||
import org.meshtastic.core.resources.firmware_update_ota_failed
|
||||
import org.meshtastic.core.resources.firmware_update_ota_unsupported_reason
|
||||
import org.meshtastic.core.resources.firmware_update_searching_device
|
||||
import org.meshtastic.core.resources.firmware_update_starting_ota
|
||||
import org.meshtastic.core.resources.firmware_update_uploading
|
||||
import org.meshtastic.core.resources.firmware_update_waiting_reboot
|
||||
@@ -53,17 +60,79 @@ import org.meshtastic.feature.firmware.stripFormatArgs
|
||||
|
||||
private const val RETRY_DELAY = 2000L
|
||||
private const val PERCENT_MAX = 100
|
||||
private const val REBOOT_MODE_BLE = 1
|
||||
private const val REBOOT_MODE_WIFI = 2
|
||||
|
||||
// Time to wait for OTA reboot packet to be sent before disconnecting mesh service
|
||||
private const val PACKET_SEND_DELAY_MS = 2000L
|
||||
// Time to wait for the firmware to confirm OTA entry before tearing down the mesh transport.
|
||||
// The firmware emits a ClientNotification ("Rebooting to <mode> OTA" on success, a "Cannot start OTA: ..." /
|
||||
// "OTA Loader does not support ..." warning on rejection) before its 1 s scheduled reboot — so this only needs to
|
||||
// cover BLE/TCP round-trip + device processing, not the reboot itself.
|
||||
private const val OTA_PREFLIGHT_TIMEOUT_MS = 5000L
|
||||
|
||||
// Time to wait for BLE GATT to fully release after disconnecting mesh service
|
||||
private const val GATT_RELEASE_DELAY_MS = 1000L
|
||||
|
||||
// Wi-Fi OTA loader needs time to reboot, rejoin the network, and start its TCP OTA server after confirmation.
|
||||
private const val WIFI_OTA_READINESS_DELAY_MS = 8_000L
|
||||
|
||||
// Firmware emits one of these human-readable messages via meshtastic.ClientNotification before rebooting.
|
||||
// "Rebooting to BLE OTA" / "Rebooting to WiFi OTA" -> success. Any other OTA status message -> rejection.
|
||||
private const val OTA_CONFIRM_PREFIX = "Rebooting to"
|
||||
|
||||
// A BLE target is a 6-octet MAC (e.g. AA:BB:CC:DD:EE:FF). Matching the exact MAC shape — not merely "contains a colon"
|
||||
// — keeps an IPv6 WiFi target (which also has colons) from being misrouted to the BLE path.
|
||||
private val MAC_ADDRESS_REGEX = Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")
|
||||
|
||||
private data class TransportFactoryResolution(
|
||||
val factory: () -> UnifiedOtaProtocol,
|
||||
val readinessAlreadyWaited: Boolean,
|
||||
)
|
||||
|
||||
interface Esp32OtaUpdateEnvironment {
|
||||
val otaPreflightTimeoutMs: Long
|
||||
val otaTransportRetryDelayMs: Long
|
||||
val wifiOtaReadinessDelayMs: Long
|
||||
val gattReleaseDelayMs: Long
|
||||
val wifiDiscoveryEnabled: Boolean
|
||||
|
||||
suspend fun delay(milliseconds: Long)
|
||||
|
||||
fun createBleTransport(
|
||||
bleScanner: BleScanner,
|
||||
bleConnectionFactory: BleConnectionFactory,
|
||||
address: String,
|
||||
dispatcher: CoroutineDispatcher,
|
||||
): UnifiedOtaProtocol
|
||||
|
||||
fun createWifiTransport(deviceIp: String): UnifiedOtaProtocol
|
||||
|
||||
suspend fun discoverWifiOtaDevice(): String?
|
||||
}
|
||||
|
||||
@Single(binds = [Esp32OtaUpdateEnvironment::class])
|
||||
class DefaultEsp32OtaUpdateEnvironment : Esp32OtaUpdateEnvironment {
|
||||
override val otaPreflightTimeoutMs: Long = OTA_PREFLIGHT_TIMEOUT_MS
|
||||
override val otaTransportRetryDelayMs: Long = RETRY_DELAY
|
||||
override val wifiOtaReadinessDelayMs: Long = WIFI_OTA_READINESS_DELAY_MS
|
||||
override val gattReleaseDelayMs: Long = GATT_RELEASE_DELAY_MS
|
||||
override val wifiDiscoveryEnabled: Boolean = true
|
||||
|
||||
override suspend fun delay(milliseconds: Long) {
|
||||
kotlinx.coroutines.delay(milliseconds)
|
||||
}
|
||||
|
||||
override fun createBleTransport(
|
||||
bleScanner: BleScanner,
|
||||
bleConnectionFactory: BleConnectionFactory,
|
||||
address: String,
|
||||
dispatcher: CoroutineDispatcher,
|
||||
): UnifiedOtaProtocol = BleOtaTransport(bleScanner, bleConnectionFactory, address, dispatcher)
|
||||
|
||||
override fun createWifiTransport(deviceIp: String): UnifiedOtaProtocol = WifiOtaTransport(deviceIp)
|
||||
|
||||
override suspend fun discoverWifiOtaDevice(): String? = WifiOtaDiscovery.discoverOtaDevice()
|
||||
}
|
||||
|
||||
internal fun isBleMacAddress(target: String): Boolean = MAC_ADDRESS_REGEX.matches(target)
|
||||
|
||||
/**
|
||||
@@ -79,6 +148,8 @@ class Esp32OtaUpdateHandler(
|
||||
private val firmwareFileHandler: FirmwareFileHandler,
|
||||
private val radioController: RadioController,
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val firmwareUpdateStatusRepository: FirmwareUpdateStatusRepository,
|
||||
private val environment: Esp32OtaUpdateEnvironment,
|
||||
private val bleScanner: BleScanner,
|
||||
private val bleConnectionFactory: BleConnectionFactory,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
@@ -108,9 +179,12 @@ class Esp32OtaUpdateHandler(
|
||||
hardware = hardware,
|
||||
updateState = updateState,
|
||||
firmwareUri = firmwareUri,
|
||||
transportFactory = { BleOtaTransport(bleScanner, bleConnectionFactory, address, dispatchers.default) },
|
||||
rebootMode = 1,
|
||||
transportFactory = {
|
||||
environment.createBleTransport(bleScanner, bleConnectionFactory, address, dispatchers.default)
|
||||
},
|
||||
rebootMode = REBOOT_MODE_BLE,
|
||||
connectionAttempts = 5,
|
||||
postConfirmReadinessDelayMs = 0L,
|
||||
)
|
||||
|
||||
private suspend fun startWifiUpdate(
|
||||
@@ -124,9 +198,10 @@ class Esp32OtaUpdateHandler(
|
||||
hardware = hardware,
|
||||
updateState = updateState,
|
||||
firmwareUri = firmwareUri,
|
||||
transportFactory = { WifiOtaTransport(deviceIp, WifiOtaTransport.DEFAULT_PORT) },
|
||||
rebootMode = 2,
|
||||
transportFactory = { environment.createWifiTransport(deviceIp) },
|
||||
rebootMode = REBOOT_MODE_WIFI,
|
||||
connectionAttempts = 10,
|
||||
postConfirmReadinessDelayMs = environment.wifiOtaReadinessDelayMs,
|
||||
)
|
||||
|
||||
private suspend fun performUpdate(
|
||||
@@ -137,56 +212,113 @@ class Esp32OtaUpdateHandler(
|
||||
transportFactory: () -> UnifiedOtaProtocol,
|
||||
rebootMode: Int,
|
||||
connectionAttempts: Int,
|
||||
postConfirmReadinessDelayMs: Long,
|
||||
): FirmwareArtifact? {
|
||||
firmwareUpdateStatusRepository.beginOtaUpdate()
|
||||
var cleanupArtifact: FirmwareArtifact? = null
|
||||
return try {
|
||||
withContext(ioDispatcher) {
|
||||
// Step 1: Get firmware file
|
||||
cleanupArtifact = obtainFirmwareFile(release, hardware, firmwareUri, updateState)
|
||||
val firmwareFile = cleanupArtifact ?: return@withContext null
|
||||
|
||||
// Step 2: Read firmware once and calculate hash
|
||||
val firmwareBytes = firmwareFileHandler.readBytes(firmwareFile)
|
||||
val sha256Bytes = FirmwareHashUtil.calculateSha256Bytes(firmwareBytes)
|
||||
val sha256Hash = FirmwareHashUtil.bytesToHex(sha256Bytes)
|
||||
Logger.i { "ESP32 OTA: Firmware hash: $sha256Hash (${firmwareBytes.size} bytes)" }
|
||||
triggerRebootOta(rebootMode, sha256Bytes)
|
||||
|
||||
// Step 3: Wait for packet to be sent, then disconnect mesh service
|
||||
// The packet needs ~1-2 seconds to be written and acknowledged over BLE
|
||||
delay(PACKET_SEND_DELAY_MS)
|
||||
disconnectMeshService()
|
||||
// Give BLE stack time to fully release the GATT connection
|
||||
delay(GATT_RELEASE_DELAY_MS)
|
||||
|
||||
val transport = transportFactory()
|
||||
connectToDevice(transport, connectionAttempts, updateState)
|
||||
|
||||
try {
|
||||
executeOtaSequence(transport, firmwareBytes, sha256Hash, rebootMode, updateState)
|
||||
firmwareFile
|
||||
} finally {
|
||||
transport.close()
|
||||
try {
|
||||
withContext(ioDispatcher) {
|
||||
performUpdateFlow(
|
||||
release,
|
||||
hardware,
|
||||
firmwareUri,
|
||||
updateState,
|
||||
transportFactory,
|
||||
rebootMode,
|
||||
connectionAttempts,
|
||||
postConfirmReadinessDelayMs,
|
||||
)
|
||||
.also { cleanupArtifact = it }
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: OtaProtocolException.HashRejected) {
|
||||
Logger.e(e) { "ESP32 OTA: Hash rejected by device" }
|
||||
updateState(FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_hash_rejected)))
|
||||
cleanupArtifact
|
||||
} catch (e: OtaProtocolException) {
|
||||
Logger.e(e) { "ESP32 OTA: Protocol error" }
|
||||
updateState(
|
||||
FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_ota_failed, e.message ?: "")),
|
||||
)
|
||||
cleanupArtifact
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.e(e) { "ESP32 OTA: Unexpected error" }
|
||||
updateState(
|
||||
FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_ota_failed, e.message ?: "")),
|
||||
)
|
||||
cleanupArtifact
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: OtaProtocolException.HashRejected) {
|
||||
Logger.e(e) { "ESP32 OTA: Hash rejected by device" }
|
||||
updateState(FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_hash_rejected)))
|
||||
cleanupArtifact
|
||||
} catch (e: OtaProtocolException) {
|
||||
Logger.e(e) { "ESP32 OTA: Protocol error" }
|
||||
updateState(
|
||||
FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_ota_failed, e.message ?: "")),
|
||||
} finally {
|
||||
firmwareUpdateStatusRepository.endOtaUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
// Extracted from performUpdate to stay under detekt LongMethod threshold (60). Single linear OTA flow:
|
||||
// obtain file → hash → preflight (clearClientNotification + trigger + runOtaPreflight) → connect → execute.
|
||||
@Suppress("ReturnCount") // three distinct early-exit paths in a linear pipeline
|
||||
private suspend fun performUpdateFlow(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
firmwareUri: CommonUri?,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
transportFactory: () -> UnifiedOtaProtocol,
|
||||
rebootMode: Int,
|
||||
connectionAttempts: Int,
|
||||
postConfirmReadinessDelayMs: Long,
|
||||
): FirmwareArtifact? {
|
||||
// Step 1: Get firmware file
|
||||
val firmwareFile = obtainFirmwareFile(release, hardware, firmwareUri, updateState) ?: return null
|
||||
|
||||
// Step 2: Read firmware once and calculate hash
|
||||
val firmwareBytes = firmwareFileHandler.readBytes(firmwareFile)
|
||||
val sha256Bytes = FirmwareHashUtil.calculateSha256Bytes(firmwareBytes)
|
||||
val sha256Hash = FirmwareHashUtil.bytesToHex(sha256Bytes)
|
||||
Logger.i { "ESP32 OTA: Firmware hash: $sha256Hash (${firmwareBytes.size} bytes)" }
|
||||
|
||||
firmwareUpdateStatusRepository.beginOtaPreflight()
|
||||
try {
|
||||
// Clear any stale notification from a previous attempt so the firmware's response is always
|
||||
// treated as a fresh emission (StateFlow conflates identical values — a retry would otherwise
|
||||
// time out waiting for a value that the flow already holds).
|
||||
radioController.clearClientNotification()
|
||||
|
||||
// Snapshot the current notification message BEFORE sending the OTA trigger. After the clear
|
||||
// above this is null today, but capturing it remains defense-in-depth: if the clear is ever
|
||||
// removed or the value is repopulated between the clear and the trigger, the predicate must
|
||||
// still distinguish the response from a pre-existing value.
|
||||
val baselineMessage = radioController.clientNotification.value?.message
|
||||
triggerRebootOta(rebootMode, sha256Bytes)
|
||||
|
||||
// Step 3: Preflight — wait for the firmware to confirm OTA entry BEFORE tearing down the mesh
|
||||
// transport. The mesh ACK only confirms packet delivery; it does NOT mean the device's OTA
|
||||
// loader supports the requested transport. The firmware surfaces loader-capability failures as
|
||||
// a meshtastic.ClientNotification warning ("OTA Loader does not support <mode>", etc.) emitted
|
||||
// before its scheduled reboot, so we race that signal against a bounded timeout. On rejection
|
||||
// we fail fast and leave the mesh connection intact. On timeout we preserve legacy behavior by
|
||||
// releasing the mesh transport and trying the OTA path; older firmware may be silent.
|
||||
if (!runOtaPreflight(rebootMode, baselineMessage, updateState).continueToOtaTransport) {
|
||||
return firmwareFile
|
||||
}
|
||||
} finally {
|
||||
firmwareUpdateStatusRepository.finishOtaPreflight()
|
||||
}
|
||||
|
||||
val transport =
|
||||
connectToDevice(
|
||||
transportFactory = transportFactory,
|
||||
attempts = connectionAttempts,
|
||||
rebootMode = rebootMode,
|
||||
postConfirmReadinessDelayMs = postConfirmReadinessDelayMs,
|
||||
updateState = updateState,
|
||||
)
|
||||
cleanupArtifact
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.e(e) { "ESP32 OTA: Unexpected error" }
|
||||
updateState(
|
||||
FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_ota_failed, e.message ?: "")),
|
||||
)
|
||||
cleanupArtifact
|
||||
|
||||
try {
|
||||
executeOtaSequence(transport, firmwareBytes, sha256Hash, rebootMode, updateState)
|
||||
return firmwareFile
|
||||
} finally {
|
||||
transport.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +343,14 @@ class Esp32OtaUpdateHandler(
|
||||
hardware: DeviceHardware,
|
||||
firmwareUri: CommonUri?,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
): FirmwareArtifact? {
|
||||
): FirmwareArtifact? = if (firmwareUri != null) {
|
||||
updateState(
|
||||
FirmwareUpdateState.Processing(
|
||||
ProgressState(message = UiText.Resource(Res.string.firmware_update_extracting)),
|
||||
),
|
||||
)
|
||||
firmwareFileHandler.importFromUri(firmwareUri)
|
||||
} else {
|
||||
val downloadingMsg = getStringSuspend(Res.string.firmware_update_downloading_percent, 0).stripFormatArgs()
|
||||
|
||||
updateState(
|
||||
@@ -220,65 +359,164 @@ class Esp32OtaUpdateHandler(
|
||||
),
|
||||
)
|
||||
|
||||
return if (firmwareUri != null) {
|
||||
updateState(
|
||||
FirmwareUpdateState.Processing(
|
||||
ProgressState(message = UiText.Resource(Res.string.firmware_update_extracting)),
|
||||
),
|
||||
)
|
||||
firmwareFileHandler.importFromUri(firmwareUri)
|
||||
} else {
|
||||
val firmwareFile =
|
||||
firmwareRetriever.retrieveEsp32Firmware(release, hardware) { progress ->
|
||||
val percent = (progress * PERCENT_MAX).toInt()
|
||||
updateState(
|
||||
FirmwareUpdateState.Downloading(
|
||||
ProgressState(
|
||||
message = UiText.DynamicString(downloadingMsg),
|
||||
progress = progress,
|
||||
details = "$percent%",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (firmwareFile == null) {
|
||||
val firmwareFile =
|
||||
firmwareRetriever.retrieveEsp32Firmware(release, hardware) { progress ->
|
||||
val percent = (progress * PERCENT_MAX).toInt()
|
||||
updateState(
|
||||
FirmwareUpdateState.Error(
|
||||
UiText.Resource(Res.string.firmware_update_not_found_in_release, hardware.displayName),
|
||||
FirmwareUpdateState.Downloading(
|
||||
ProgressState(
|
||||
message = UiText.DynamicString(downloadingMsg),
|
||||
progress = progress,
|
||||
details = "$percent%",
|
||||
),
|
||||
),
|
||||
)
|
||||
null
|
||||
} else {
|
||||
firmwareFile
|
||||
}
|
||||
|
||||
if (firmwareFile == null) {
|
||||
updateState(
|
||||
FirmwareUpdateState.Error(
|
||||
UiText.Resource(Res.string.firmware_update_not_found_in_release, hardware.displayName),
|
||||
),
|
||||
)
|
||||
null
|
||||
} else {
|
||||
firmwareFile
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun connectToDevice(
|
||||
transport: UnifiedOtaProtocol,
|
||||
transportFactory: () -> UnifiedOtaProtocol,
|
||||
attempts: Int,
|
||||
rebootMode: Int,
|
||||
postConfirmReadinessDelayMs: Long,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
) {
|
||||
// Show "waiting for reboot" state before first connection attempt
|
||||
): UnifiedOtaProtocol {
|
||||
updateState(
|
||||
FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_waiting_reboot))),
|
||||
)
|
||||
|
||||
retryWithDelay(
|
||||
attempts = attempts,
|
||||
retryDelayMillis = RETRY_DELAY,
|
||||
onAttempt = { i ->
|
||||
updateState(
|
||||
FirmwareUpdateState.Processing(
|
||||
ProgressState(UiText.Resource(Res.string.firmware_update_connecting_attempt, i, attempts)),
|
||||
),
|
||||
)
|
||||
},
|
||||
) {
|
||||
transport.connect()
|
||||
val resolution = resolvePostConfirmTransportFactory(rebootMode, transportFactory, updateState)
|
||||
waitForPostConfirmReadiness(rebootMode, postConfirmReadinessDelayMs, resolution.readinessAlreadyWaited)
|
||||
|
||||
return runTransportConnectRetries(resolution.factory, attempts, rebootMode, updateState)
|
||||
}
|
||||
|
||||
private suspend fun resolvePostConfirmTransportFactory(
|
||||
rebootMode: Int,
|
||||
transportFactory: () -> UnifiedOtaProtocol,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
): TransportFactoryResolution {
|
||||
// In WiFi mode the device may have picked up a different DHCP lease after rebooting into the OTA loader. Listen
|
||||
// for the loader's UDP discovery broadcast and, if one arrives, redirect the TCP transport at the discovered
|
||||
// IP.
|
||||
if (rebootMode != REBOOT_MODE_WIFI || !environment.wifiDiscoveryEnabled) {
|
||||
return TransportFactoryResolution(factory = transportFactory, readinessAlreadyWaited = false)
|
||||
}
|
||||
.getOrThrow()
|
||||
|
||||
updateState(
|
||||
FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_searching_device))),
|
||||
)
|
||||
val discoveredIp = environment.discoverWifiOtaDevice()
|
||||
val factory: () -> UnifiedOtaProtocol =
|
||||
if (discoveredIp != null) {
|
||||
val discoveredFactory: () -> UnifiedOtaProtocol = { environment.createWifiTransport(discoveredIp) }
|
||||
Logger.i { "ESP32 OTA: Using UDP-discovered OTA device for TCP transport" }
|
||||
discoveredFactory
|
||||
} else {
|
||||
Logger.i { "ESP32 OTA: No UDP discovery broadcast received; falling back to configured device IP" }
|
||||
transportFactory
|
||||
}
|
||||
// Only treat the readiness window as already-spent when discovery actually resolved a device — its bounded
|
||||
// listening window covers the loader's reboot+DHCP+TCP-server bring-up. On a null result (timeout / bind
|
||||
// failure) the device has not yet been heard from, so the caller's 8 s readiness margin still applies.
|
||||
return TransportFactoryResolution(factory = factory, readinessAlreadyWaited = discoveredIp != null)
|
||||
}
|
||||
|
||||
private suspend fun waitForPostConfirmReadiness(rebootMode: Int, delayMs: Long, readinessAlreadyWaited: Boolean) {
|
||||
if (delayMs <= 0L || readinessAlreadyWaited) return
|
||||
Logger.i { "ESP32 OTA: Waiting ${delayMs}ms for ${otaModeName(rebootMode)} OTA service" }
|
||||
environment.delay(delayMs)
|
||||
}
|
||||
|
||||
private suspend fun runTransportConnectRetries(
|
||||
transportFactory: () -> UnifiedOtaProtocol,
|
||||
attempts: Int,
|
||||
rebootMode: Int,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
): UnifiedOtaProtocol {
|
||||
var connectedTransport: UnifiedOtaProtocol? = null
|
||||
val result =
|
||||
retryWithDelay(
|
||||
attempts = attempts,
|
||||
retryDelayMillis = environment.otaTransportRetryDelayMs,
|
||||
onAttempt = { i ->
|
||||
updateState(
|
||||
FirmwareUpdateState.Processing(
|
||||
ProgressState(UiText.Resource(Res.string.firmware_update_connecting_attempt, i, attempts)),
|
||||
),
|
||||
)
|
||||
},
|
||||
) {
|
||||
val transport = transportFactory()
|
||||
val connectResult = connectTransportAttempt(transport, rebootMode)
|
||||
connectResult.onSuccess { connectedTransport = transport }
|
||||
}
|
||||
|
||||
result.getOrElse { cause -> throw postConfirmConnectionFailed(rebootMode, attempts, cause) }
|
||||
return connectedTransport ?: throw postConfirmConnectionFailed(rebootMode, attempts, null)
|
||||
}
|
||||
|
||||
private suspend fun connectTransportAttempt(transport: UnifiedOtaProtocol, rebootMode: Int): Result<Unit> {
|
||||
val connectResult =
|
||||
try {
|
||||
transport.connect()
|
||||
} catch (e: CancellationException) {
|
||||
closeFailedTransport(transport)
|
||||
throw e
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
|
||||
val error = connectResult.exceptionOrNull() ?: return connectResult
|
||||
if (error is CancellationException) {
|
||||
closeFailedTransport(transport)
|
||||
throw error
|
||||
}
|
||||
Logger.w(error) {
|
||||
"ESP32 OTA: ${otaModeName(rebootMode)} connection attempt failed; closing transport before retry"
|
||||
}
|
||||
closeFailedTransport(transport)
|
||||
return Result.failure(error)
|
||||
}
|
||||
|
||||
private suspend fun closeFailedTransport(transport: UnifiedOtaProtocol) {
|
||||
withContext(NonCancellable) {
|
||||
try {
|
||||
transport.close()
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Throwable) {
|
||||
Logger.w(e) { "ESP32 OTA: Failed to close failed transport attempt" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun postConfirmConnectionFailed(
|
||||
rebootMode: Int,
|
||||
attempts: Int,
|
||||
cause: Throwable?,
|
||||
): OtaProtocolException.ConnectionFailed {
|
||||
val modeName = otaModeName(rebootMode)
|
||||
return OtaProtocolException.ConnectionFailed(
|
||||
"Device confirmed $modeName OTA mode, but the OTA service was not reachable after $attempts attempts. " +
|
||||
connectionRecoveryHint(rebootMode),
|
||||
cause,
|
||||
)
|
||||
}
|
||||
|
||||
private fun connectionRecoveryHint(rebootMode: Int): String = when (rebootMode) {
|
||||
REBOOT_MODE_WIFI -> "Make sure the phone and device are on the same network and try again."
|
||||
REBOOT_MODE_BLE -> "Keep the device nearby and try again."
|
||||
else -> "Try again."
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@@ -312,7 +550,7 @@ class Esp32OtaUpdateHandler(
|
||||
val uploadingMsg = UiText.Resource(Res.string.firmware_update_uploading)
|
||||
updateState(FirmwareUpdateState.Updating(ProgressState(uploadingMsg, 0f)))
|
||||
val chunkSize =
|
||||
if (rebootMode == 1) {
|
||||
if (rebootMode == REBOOT_MODE_BLE) {
|
||||
BleOtaTransport.RECOMMENDED_CHUNK_SIZE
|
||||
} else {
|
||||
WifiOtaTransport.RECOMMENDED_CHUNK_SIZE
|
||||
@@ -340,4 +578,114 @@ class Esp32OtaUpdateHandler(
|
||||
|
||||
updateState(FirmwareUpdateState.Success())
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of waiting for the device to confirm OTA entry after [triggerRebootOta]. The firmware emits a single
|
||||
* [org.meshtastic.proto.ClientNotification] before its scheduled reboot covering all cases: success ("Rebooting to
|
||||
* <BLE|WiFi> OTA") or rejection (any other OTA status message — partition missing, loader incompatible, switch
|
||||
* failed). Older firmware may be silent, which falls back to the previous disconnect/reconnect path.
|
||||
*/
|
||||
private sealed interface OtaPreflightResult {
|
||||
val continueToOtaTransport: Boolean
|
||||
|
||||
/** Firmware confirmed it is rebooting into the OTA loader. */
|
||||
data object Confirmed : OtaPreflightResult {
|
||||
override val continueToOtaTransport = true
|
||||
}
|
||||
|
||||
/** Firmware rejected the OTA request; [message] is the verbatim rejection reason. */
|
||||
data class Rejected(val message: String) : OtaPreflightResult {
|
||||
override val continueToOtaTransport = false
|
||||
}
|
||||
|
||||
/** No ClientNotification arrived within the bound; continue with the legacy fixed-delay path. */
|
||||
data object LegacyFallback : OtaPreflightResult {
|
||||
override val continueToOtaTransport = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the OTA preflight gate. On [OtaPreflightResult.Confirmed] this releases the mesh transport (with the GATT
|
||||
* release delay) and returns Confirmed. On rejection it emits [FirmwareUpdateState.Error] and returns the failure
|
||||
* result so the caller can abort while preserving the cleanup artifact and mesh transport. On legacy fallback it
|
||||
* releases the mesh transport and continues to the OTA connection attempts just as older app versions did.
|
||||
*/
|
||||
private suspend fun runOtaPreflight(
|
||||
rebootMode: Int,
|
||||
baselineMessage: String?,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
): OtaPreflightResult =
|
||||
when (val preflight = awaitOtaConfirmation(environment.otaPreflightTimeoutMs, rebootMode, baselineMessage)) {
|
||||
is OtaPreflightResult.Confirmed -> {
|
||||
Logger.i { "ESP32 OTA: Preflight confirmed; releasing mesh transport for OTA" }
|
||||
releaseMeshTransportForOta()
|
||||
preflight
|
||||
}
|
||||
|
||||
is OtaPreflightResult.Rejected -> {
|
||||
Logger.w { "ESP32 OTA: Firmware rejected OTA entry (${preflight.message}); mesh transport preserved" }
|
||||
updateState(
|
||||
FirmwareUpdateState.Error(
|
||||
UiText.Resource(Res.string.firmware_update_ota_unsupported_reason, preflight.message),
|
||||
),
|
||||
)
|
||||
preflight
|
||||
}
|
||||
|
||||
is OtaPreflightResult.LegacyFallback -> {
|
||||
Logger.w {
|
||||
"ESP32 OTA: No firmware confirmation within ${environment.otaPreflightTimeoutMs}ms; " +
|
||||
"using legacy OTA reconnect"
|
||||
}
|
||||
releaseMeshTransportForOta()
|
||||
preflight
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun releaseMeshTransportForOta() {
|
||||
disconnectMeshService()
|
||||
// Give BLE stack time to fully release the GATT connection.
|
||||
environment.delay(environment.gattReleaseDelayMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Race the firmware's OTA-entry ClientNotification against [timeoutMs]. The [baselineMessage] is captured by the
|
||||
* caller BEFORE the OTA trigger is sent, so a stale notification already held in the StateFlow doesn't get mistaken
|
||||
* for the response.
|
||||
*/
|
||||
private suspend fun awaitOtaConfirmation(
|
||||
timeoutMs: Long,
|
||||
rebootMode: Int,
|
||||
baselineMessage: String?,
|
||||
): OtaPreflightResult {
|
||||
val modeName = otaModeName(rebootMode)
|
||||
val matched =
|
||||
withTimeoutOrNull(timeoutMs) {
|
||||
radioController.clientNotification.first { cn ->
|
||||
cn != null && cn.message != baselineMessage && cn.isOtaStatusNotification()
|
||||
}
|
||||
}
|
||||
matched ?: return OtaPreflightResult.LegacyFallback
|
||||
|
||||
radioController.clearClientNotification()
|
||||
|
||||
return when {
|
||||
// Defense-in-depth: require both the canonical "Rebooting to" prefix AND the requested mode name in the
|
||||
// message. Firmware is consistent today, but this guards against a future firmware bug that acks the wrong
|
||||
// mode — a wrong-mode confirmation would route the host to a transport the device did not enter.
|
||||
matched.message.startsWith(OTA_CONFIRM_PREFIX, ignoreCase = true) &&
|
||||
matched.message.contains(modeName, ignoreCase = true) -> {
|
||||
Logger.i { "ESP32 OTA: Firmware confirmed $modeName OTA entry: ${matched.message}" }
|
||||
OtaPreflightResult.Confirmed
|
||||
}
|
||||
|
||||
else -> OtaPreflightResult.Rejected(matched.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun otaModeName(rebootMode: Int): String = when (rebootMode) {
|
||||
REBOOT_MODE_BLE -> "BLE"
|
||||
REBOOT_MODE_WIFI -> "WiFi"
|
||||
else -> rebootMode.toString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.firmware.ota
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import io.ktor.network.selector.SelectorManager
|
||||
import io.ktor.network.sockets.InetSocketAddress
|
||||
import io.ktor.network.sockets.SocketAddress
|
||||
import io.ktor.network.sockets.aSocket
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.io.readByteArray
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.common.util.safeCatching
|
||||
|
||||
/**
|
||||
* Listens for the ESP32 OTA loader's UDP discovery broadcast so the host can learn the device's post-reboot IP.
|
||||
*
|
||||
* After rebooting into OTA mode the loader emits a UDP broadcast to `255.255.255.255:[port]` every ~1 second. DHCP may
|
||||
* assign a different IP in OTA mode than in normal operation, so connecting to the previously-known IP can fail; this
|
||||
* discovery resolves the actual address.
|
||||
*/
|
||||
internal object WifiOtaDiscovery {
|
||||
/**
|
||||
* Listens for the OTA loader's UDP discovery broadcast on [port] and returns the sender's IP address. Returns
|
||||
* `null` on timeout, bind failure, or any other receive error — callers fall back to the original IP.
|
||||
*/
|
||||
suspend fun discoverOtaDevice(
|
||||
port: Int = WifiOtaTransport.DEFAULT_PORT,
|
||||
timeoutMs: Long = DEFAULT_TIMEOUT_MS,
|
||||
): String? = withContext(ioDispatcher) {
|
||||
// NOTE: No MulticastLock acquired — that is an Android-only API and this is commonMain. All-ones
|
||||
// limited broadcasts (255.255.255.255) are typically delivered without it; if a specific device filters
|
||||
// them, add an expect/actual multicast-lock wrapper and acquire it for the duration of [receive].
|
||||
safeCatching {
|
||||
// ponytail: var accumulator + loop-on-condition. withTimeoutOrNull's block type comes from its
|
||||
// terminal expression; an infinite `while(true){...}` whose body ends in Logger.d (Unit) makes
|
||||
// the whole block Unit, which clashes with any explicit <T> param. Accumulating into `discovered`
|
||||
// gives the block a concrete String? terminal and lets inference handle the rest.
|
||||
withTimeoutOrNull(timeoutMs) {
|
||||
val selector = SelectorManager(ioDispatcher)
|
||||
var discovered: String? = null
|
||||
try {
|
||||
val socket = aSocket(selector).udp().bind(InetSocketAddress("0.0.0.0", port))
|
||||
try {
|
||||
Logger.i { "WiFi OTA: Listening for OTA device discovery broadcast on port $port" }
|
||||
while (discovered == null) {
|
||||
val datagram = socket.receive()
|
||||
val candidate = senderHost(datagram.address)
|
||||
val payload = datagram.packet.readByteArray()
|
||||
if (candidate != null && isOtaDiscoveryBeacon(payload)) {
|
||||
Logger.i { "WiFi OTA: Discovered OTA device broadcast" }
|
||||
discovered = candidate
|
||||
} else {
|
||||
Logger.d { "WiFi OTA: Ignoring non-Meshtastic OTA discovery datagram" }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
socket.close()
|
||||
}
|
||||
} finally {
|
||||
selector.close()
|
||||
}
|
||||
discovered
|
||||
}
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount") // early-out paths for different address shapes
|
||||
internal fun senderHost(address: SocketAddress): String? {
|
||||
val sock = address as? InetSocketAddress ?: return null
|
||||
// InetSocketAddress.hostname may trigger a reverse-DNS lookup; use only the raw IPv4 bytes returned by
|
||||
// resolveAddress() (4 bytes for IPv4 — the only shape ESP32 OTA loader broadcasts).
|
||||
val bytes = sock.resolveAddress()
|
||||
if (bytes != null && bytes.size == IPV4_ADDRESS_BYTES) {
|
||||
return bytes.joinToString(".") { (it.toInt() and BYTE_MASK).toString() }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
internal fun isOtaDiscoveryBeacon(payload: ByteArray): Boolean {
|
||||
val message = payload.decodeToString().trim()
|
||||
return OTA_DISCOVERY_PATTERN.matches(message)
|
||||
}
|
||||
|
||||
// esp32-unified-ota broadcasts "<deviceName> <version>", where deviceName is "Meshtastic_" plus 4 MAC hex digits.
|
||||
private val OTA_DISCOVERY_PATTERN = Regex("""^Meshtastic_[0-9A-Fa-f]{4}\s+\S{1,32}$""")
|
||||
private const val IPV4_ADDRESS_BYTES = 4
|
||||
private const val BYTE_MASK = 0xFF
|
||||
private const val DEFAULT_TIMEOUT_MS = 3_000L
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.firmware.ota
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.everySuspend
|
||||
import dev.mokkery.matcher.any
|
||||
import dev.mokkery.mock
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
|
||||
import org.meshtastic.core.testing.FakeNodeRepository
|
||||
import org.meshtastic.core.testing.FakeRadioController
|
||||
import org.meshtastic.core.testing.TestDataFactory
|
||||
import org.meshtastic.feature.firmware.FirmwareArtifact
|
||||
import org.meshtastic.feature.firmware.FirmwareFileHandler
|
||||
import org.meshtastic.feature.firmware.FirmwareRetriever
|
||||
import org.meshtastic.feature.firmware.FirmwareUpdateState
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Tests for [Esp32OtaUpdateHandler]'s OTA preflight gate — the bounded wait for firmware confirmation between
|
||||
* `triggerRebootOta` and tearing down the mesh transport.
|
||||
*
|
||||
* Each test drives `startUpdate` end-to-end just far enough to observe the preflight outcome:
|
||||
* - Confirmed → mesh disconnect (`setDeviceAddress("n")`) is invoked, then the handler proceeds into the BLE transport
|
||||
* phase, which fails fast on the mocked BLE deps. We bound the call with `withTimeoutOrNull` so the post-preflight
|
||||
* BLE-connect retry loop is cancelled before it costs real wall-clock time.
|
||||
* - Rejected → mesh transport is preserved (no `setDeviceAddress`) and an `Error` state is emitted; the handler returns
|
||||
* cleanly with no exception to chase.
|
||||
* - Silent firmware → legacy fallback disconnects the mesh transport and proceeds toward OTA connection attempts.
|
||||
*
|
||||
* The firmware response is simulated by mutating [FakeRadioController.clientNotification] from a sibling coroutine
|
||||
* scheduled to fire after `triggerRebootOta`. Because baseline is captured BEFORE the trigger (see `performUpdate`), a
|
||||
* value written afterward is recognized as the response, not the baseline.
|
||||
*/
|
||||
class Esp32OtaUpdateHandlerTest {
|
||||
|
||||
private val release = FirmwareRelease(id = "v1.0", title = "test")
|
||||
private val hardware =
|
||||
DeviceHardware(hwModelSlug = "HELTEC_V3", platformioTarget = "heltec-v3", architecture = "esp32-s3")
|
||||
private val firmwareUri = CommonUri.parse("file:///tmp/firmware.bin")
|
||||
|
||||
private val dispatchers =
|
||||
CoroutineDispatchers(
|
||||
io = Dispatchers.Unconfined,
|
||||
main = Dispatchers.Unconfined,
|
||||
default = Dispatchers.Unconfined,
|
||||
)
|
||||
|
||||
private fun makeHandler(
|
||||
radioController: FakeRadioController,
|
||||
nodeRepository: FakeNodeRepository,
|
||||
fileHandler: FirmwareFileHandler,
|
||||
firmwareUpdateStatusRepository: FirmwareUpdateStatusRepository,
|
||||
environment: TestEsp32OtaUpdateEnvironment,
|
||||
): Esp32OtaUpdateHandler = Esp32OtaUpdateHandler(
|
||||
firmwareRetriever = FirmwareRetriever(fileHandler),
|
||||
firmwareFileHandler = fileHandler,
|
||||
radioController = radioController,
|
||||
nodeRepository = nodeRepository,
|
||||
firmwareUpdateStatusRepository = firmwareUpdateStatusRepository,
|
||||
environment = environment,
|
||||
bleScanner = mock(MockMode.autofill),
|
||||
bleConnectionFactory = mock(MockMode.autofill),
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
|
||||
private fun newFileHandler(): FirmwareFileHandler = mock(MockMode.autofill) {
|
||||
everySuspend { readBytes(any()) } returns byteArrayOf(1, 2, 3, 4)
|
||||
everySuspend { importFromUri(any()) } returns
|
||||
FirmwareArtifact(uri = firmwareUri, fileName = "firmware.bin", isTemporary = false)
|
||||
}
|
||||
|
||||
private fun newNodeRepository(): FakeNodeRepository = FakeNodeRepository().apply {
|
||||
setMyNodeInfo(TestDataFactory.createMyNodeInfo(myNodeNum = 1, firmwareVersion = "1.0"))
|
||||
}
|
||||
|
||||
/** Schedule [block] on a sibling Default dispatcher; cancelled at the end of the test. */
|
||||
private fun runSideEffect(block: suspend CoroutineScope.() -> Unit): Job =
|
||||
CoroutineScope(Dispatchers.Default).launch { block() }
|
||||
|
||||
/**
|
||||
* Shared fixture builder for preflight tests. Schedules a sibling coroutine that emits [confirmationMessage] as a
|
||||
* firmware ClientNotification after [CONFIRMATION_DELAY_MS]; pass `confirmationMessage = null` to skip the emitter
|
||||
* entirely and exercise the legacy fallback path.
|
||||
*/
|
||||
private fun runPreflightTest(
|
||||
target: String = BLE_TARGET,
|
||||
confirmationMessage: String? = BLE_CONFIRMATION,
|
||||
configure: TestEsp32OtaUpdateEnvironment.() -> Unit = {},
|
||||
runUpdate: suspend PreflightFixture.() -> Unit = {
|
||||
handler.startUpdate(release, hardware, target, states::add, firmwareUri)
|
||||
},
|
||||
assertions: PreflightFixture.() -> Unit,
|
||||
) = runBlocking {
|
||||
val radioController = FakeRadioController()
|
||||
val nodeRepository = newNodeRepository()
|
||||
val firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository()
|
||||
val environment = TestEsp32OtaUpdateEnvironment().apply(configure)
|
||||
val handler =
|
||||
makeHandler(
|
||||
radioController = radioController,
|
||||
nodeRepository = nodeRepository,
|
||||
fileHandler = newFileHandler(),
|
||||
firmwareUpdateStatusRepository = firmwareUpdateStatusRepository,
|
||||
environment = environment,
|
||||
)
|
||||
val states = mutableListOf<FirmwareUpdateState>()
|
||||
val fixture = PreflightFixture(handler = handler, radioController = radioController, states = states)
|
||||
|
||||
val emitter =
|
||||
confirmationMessage?.let { msg ->
|
||||
runSideEffect {
|
||||
delay(CONFIRMATION_DELAY_MS)
|
||||
radioController.setClientNotification(ClientNotification(message = msg))
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fixture.runUpdate()
|
||||
} finally {
|
||||
emitter?.cancel()
|
||||
}
|
||||
|
||||
fixture.assertions()
|
||||
assertFalse(
|
||||
firmwareUpdateStatusRepository.status.value.isOtaUpdateActive,
|
||||
"Firmware update status must reset after the handler finishes or is cancelled",
|
||||
)
|
||||
}
|
||||
|
||||
// ── Confirmed ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `Confirmed preflight releases the mesh transport`() = runPreflightTest(
|
||||
target = BLE_TARGET,
|
||||
confirmationMessage = BLE_CONFIRMATION,
|
||||
runUpdate = {
|
||||
// Bound the call: we only need to observe the preflight side effect. The post-preflight BLE transport
|
||||
// phase would otherwise burn ~30 s of real wall-clock time on retry/delay loops with mocked BLE deps.
|
||||
withTimeoutOrNull(3000L) {
|
||||
handler.startUpdate(release, hardware, BLE_TARGET, states::add, firmwareUri)
|
||||
}
|
||||
},
|
||||
) {
|
||||
assertEquals("n", radioController.lastSetDeviceAddress, "Confirmed preflight must disconnect mesh service")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Confirmed BLE preflight retries with fresh transport after connection failure`() {
|
||||
val events = mutableListOf<String>()
|
||||
val transports =
|
||||
ArrayDeque<FakeOtaTransport>().apply {
|
||||
add(
|
||||
FakeOtaTransport(
|
||||
name = "first",
|
||||
events = events,
|
||||
connectResult = Result.failure(OtaProtocolException.ConnectionFailed("OTA service missing")),
|
||||
),
|
||||
)
|
||||
add(FakeOtaTransport(name = "second", events = events))
|
||||
}
|
||||
runPreflightTest(
|
||||
target = BLE_TARGET,
|
||||
confirmationMessage = BLE_CONFIRMATION,
|
||||
configure = {
|
||||
gattReleaseDelayMs = 0L
|
||||
otaTransportRetryDelayMs = 0L
|
||||
bleTransportFactory = { transports.removeFirst() }
|
||||
},
|
||||
) {
|
||||
assertEquals("n", radioController.lastSetDeviceAddress, "Confirmed preflight must disconnect mesh service")
|
||||
assertTrue(events.contains("close:first"), "Failed transport must be closed before retry")
|
||||
assertTrue(events.contains("connect:second"), "Retry must create and connect a fresh transport")
|
||||
assertTrue(events.indexOf("close:first") < events.indexOf("connect:second"))
|
||||
assertTrue(events.contains("close:second"), "Successful transport must be closed after transfer")
|
||||
assertTrue(states.any { it is FirmwareUpdateState.Success }, "Later fresh transport should complete update")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Confirmed WiFi preflight waits for OTA service readiness before connecting`() {
|
||||
val events = mutableListOf<String>()
|
||||
runPreflightTest(
|
||||
target = WIFI_TARGET,
|
||||
confirmationMessage = WIFI_CONFIRMATION,
|
||||
configure = {
|
||||
gattReleaseDelayMs = 0L
|
||||
wifiOtaReadinessDelayMs = TEST_WIFI_READINESS_DELAY_MS
|
||||
delayBlock = { delayMs -> events += "delay:$delayMs" }
|
||||
wifiTransportFactory = { FakeOtaTransport(name = "wifi", events = events) }
|
||||
},
|
||||
) {
|
||||
assertTrue(
|
||||
events.contains("delay:$TEST_WIFI_READINESS_DELAY_MS"),
|
||||
"WiFi post-confirm readiness delay must run",
|
||||
)
|
||||
assertTrue(events.contains("connect:wifi"), "WiFi transport must connect after readiness delay")
|
||||
assertTrue(events.indexOf("delay:$TEST_WIFI_READINESS_DELAY_MS") < events.indexOf("connect:wifi"))
|
||||
assertTrue(
|
||||
states.any { it is FirmwareUpdateState.Success },
|
||||
"WiFi transport should connect after readiness delay",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Confirmed WiFi preflight uses UDP discovered address before connecting`() {
|
||||
val events = mutableListOf<String>()
|
||||
runPreflightTest(
|
||||
target = WIFI_TARGET,
|
||||
confirmationMessage = WIFI_CONFIRMATION,
|
||||
configure = {
|
||||
gattReleaseDelayMs = 0L
|
||||
wifiOtaReadinessDelayMs = TEST_WIFI_READINESS_DELAY_MS
|
||||
delayBlock = { delayMs -> events += "delay:$delayMs" }
|
||||
wifiDiscoveryEnabled = true
|
||||
discoverWifiOtaDeviceBlock = {
|
||||
events += "discover"
|
||||
DISCOVERED_WIFI_TARGET
|
||||
}
|
||||
wifiTransportFactory = { target ->
|
||||
events += "factory:$target"
|
||||
FakeOtaTransport(name = target, events = events)
|
||||
}
|
||||
},
|
||||
) {
|
||||
assertTrue(events.contains("discover"), "WiFi OTA should listen for the loader's DHCP address")
|
||||
assertTrue(
|
||||
events.contains("factory:$DISCOVERED_WIFI_TARGET"),
|
||||
"Discovered DHCP address must replace the pre-reboot address",
|
||||
)
|
||||
assertTrue(
|
||||
events.contains("connect:$DISCOVERED_WIFI_TARGET"),
|
||||
"WiFi transport must connect to the discovered address",
|
||||
)
|
||||
assertTrue(
|
||||
events.none { it == "factory:$WIFI_TARGET" || it == "connect:$WIFI_TARGET" },
|
||||
"Configured pre-reboot address should not be used after discovery succeeds",
|
||||
)
|
||||
assertTrue(
|
||||
events.none { it == "delay:$TEST_WIFI_READINESS_DELAY_MS" },
|
||||
"Discovery timeout window counts as the post-confirm readiness wait",
|
||||
)
|
||||
assertTrue(states.any { it is FirmwareUpdateState.Success })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Confirmed WiFi preflight falls back to configured address when UDP discovery misses`() {
|
||||
val events = mutableListOf<String>()
|
||||
runPreflightTest(
|
||||
target = WIFI_TARGET,
|
||||
confirmationMessage = WIFI_CONFIRMATION,
|
||||
configure = {
|
||||
gattReleaseDelayMs = 0L
|
||||
wifiOtaReadinessDelayMs = TEST_WIFI_READINESS_DELAY_MS
|
||||
delayBlock = { delayMs -> events += "delay:$delayMs" }
|
||||
wifiDiscoveryEnabled = true
|
||||
discoverWifiOtaDeviceBlock = {
|
||||
events += "discover"
|
||||
null
|
||||
}
|
||||
wifiTransportFactory = { target ->
|
||||
events += "factory:$target"
|
||||
FakeOtaTransport(name = target, events = events)
|
||||
}
|
||||
},
|
||||
) {
|
||||
assertTrue(events.contains("discover"), "WiFi OTA should attempt UDP discovery before fixed-IP fallback")
|
||||
assertTrue(events.contains("factory:$WIFI_TARGET"), "Missing discovery must fall back to configured target")
|
||||
assertTrue(events.contains("connect:$WIFI_TARGET"), "Fallback transport must still connect")
|
||||
assertTrue(
|
||||
events.contains("delay:$TEST_WIFI_READINESS_DELAY_MS"),
|
||||
"Discovery miss must still apply the post-confirm readiness margin",
|
||||
)
|
||||
assertTrue(
|
||||
events.indexOf("delay:$TEST_WIFI_READINESS_DELAY_MS") < events.indexOf("connect:$WIFI_TARGET"),
|
||||
"Readiness delay must run before connecting",
|
||||
)
|
||||
assertTrue(states.any { it is FirmwareUpdateState.Success })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Confirmed WiFi preflight retries with fresh transport after connection failure`() {
|
||||
val events = mutableListOf<String>()
|
||||
val transports =
|
||||
ArrayDeque<FakeOtaTransport>().apply {
|
||||
add(
|
||||
FakeOtaTransport(
|
||||
name = "wifi-first",
|
||||
events = events,
|
||||
connectResult = Result.failure(OtaProtocolException.ConnectionFailed("Connection refused")),
|
||||
),
|
||||
)
|
||||
add(FakeOtaTransport(name = "wifi-second", events = events))
|
||||
}
|
||||
runPreflightTest(
|
||||
target = WIFI_TARGET,
|
||||
confirmationMessage = WIFI_CONFIRMATION,
|
||||
configure = {
|
||||
gattReleaseDelayMs = 0L
|
||||
wifiOtaReadinessDelayMs = 0L
|
||||
otaTransportRetryDelayMs = 0L
|
||||
wifiTransportFactory = { transports.removeFirst() }
|
||||
},
|
||||
) {
|
||||
assertEquals("n", radioController.lastSetDeviceAddress, "Confirmed preflight must disconnect mesh service")
|
||||
assertTrue(events.contains("close:wifi-first"), "Failed WiFi transport must be closed before retry")
|
||||
assertTrue(events.contains("connect:wifi-second"), "WiFi retry must create and connect a fresh transport")
|
||||
assertTrue(events.indexOf("close:wifi-first") < events.indexOf("connect:wifi-second"))
|
||||
assertTrue(events.contains("close:wifi-second"), "Successful WiFi transport must be closed after transfer")
|
||||
assertTrue(
|
||||
states.any { it is FirmwareUpdateState.Success },
|
||||
"Later fresh WiFi transport should complete update",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Confirmed WiFi preflight closes failed transports and surfaces connection error after retries`() {
|
||||
val events = mutableListOf<String>()
|
||||
var attempt = 0
|
||||
runPreflightTest(
|
||||
target = WIFI_TARGET,
|
||||
confirmationMessage = WIFI_CONFIRMATION,
|
||||
configure = {
|
||||
gattReleaseDelayMs = 0L
|
||||
wifiOtaReadinessDelayMs = 0L
|
||||
otaTransportRetryDelayMs = 0L
|
||||
wifiTransportFactory = {
|
||||
attempt += 1
|
||||
FakeOtaTransport(
|
||||
name = "wifi-$attempt",
|
||||
events = events,
|
||||
connectResult = Result.failure(OtaProtocolException.ConnectionFailed("Connection refused")),
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
assertEquals("n", radioController.lastSetDeviceAddress, "Confirmed preflight must disconnect mesh service")
|
||||
assertEquals(WIFI_CONNECT_ATTEMPTS, attempt, "WiFi should use the full post-confirm retry budget")
|
||||
repeat(WIFI_CONNECT_ATTEMPTS) { index ->
|
||||
val name = "wifi-${index + 1}"
|
||||
assertTrue(events.contains("connect:$name"), "Attempt ${index + 1} must connect a fresh transport")
|
||||
assertTrue(events.contains("close:$name"), "Attempt ${index + 1} must close its failed transport")
|
||||
assertTrue(events.indexOf("connect:$name") < events.indexOf("close:$name"))
|
||||
}
|
||||
assertTrue(events.none { it.startsWith("start:") || it.startsWith("stream:") })
|
||||
assertIs<FirmwareUpdateState.Error>(states.lastOrNull())
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Confirmed BLE preflight closes failed transports and surfaces connection error after retries`() {
|
||||
val events = mutableListOf<String>()
|
||||
var attempt = 0
|
||||
runPreflightTest(
|
||||
target = BLE_TARGET,
|
||||
confirmationMessage = BLE_CONFIRMATION,
|
||||
configure = {
|
||||
gattReleaseDelayMs = 0L
|
||||
otaTransportRetryDelayMs = 0L
|
||||
bleTransportFactory = {
|
||||
attempt += 1
|
||||
FakeOtaTransport(
|
||||
name = "ble-$attempt",
|
||||
events = events,
|
||||
connectResult = Result.failure(OtaProtocolException.ConnectionFailed("GATT unreachable")),
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
assertEquals("n", radioController.lastSetDeviceAddress, "Confirmed preflight must disconnect mesh service")
|
||||
assertEquals(BLE_CONNECT_ATTEMPTS, attempt, "BLE should use the full post-confirm retry budget")
|
||||
repeat(BLE_CONNECT_ATTEMPTS) { index ->
|
||||
val name = "ble-${index + 1}"
|
||||
assertTrue(events.contains("connect:$name"), "Attempt ${index + 1} must connect a fresh transport")
|
||||
assertTrue(events.contains("close:$name"), "Attempt ${index + 1} must close its failed transport")
|
||||
assertTrue(events.indexOf("connect:$name") < events.indexOf("close:$name"))
|
||||
}
|
||||
assertTrue(events.none { it.startsWith("start:") || it.startsWith("stream:") })
|
||||
assertIs<FirmwareUpdateState.Error>(states.lastOrNull())
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rejected ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `Rejected preflight preserves mesh transport and surfaces Error`() = runPreflightTest(
|
||||
// OTA-keyed message that is NOT the canonical "Rebooting to <mode> OTA" success prefix → Rejected.
|
||||
confirmationMessage = "Cannot start OTA: OTA Loader partition not found.",
|
||||
) {
|
||||
assertNull(radioController.lastSetDeviceAddress, "Rejected preflight must NOT disconnect mesh service")
|
||||
assertTrue(states.any { it is FirmwareUpdateState.Error }, "Rejected preflight must emit Error state")
|
||||
}
|
||||
|
||||
// ── Legacy fallback ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `Silent preflight falls back to legacy OTA reconnect path`() = runPreflightTest(
|
||||
// No emitter (confirmationMessage = null): older firmware may be silent, so the overridden preflight
|
||||
// timeout
|
||||
// should resolve quickly into the same disconnect/reconnect path the app used before preflight
|
||||
// confirmation.
|
||||
confirmationMessage = null,
|
||||
configure = {
|
||||
otaPreflightTimeoutMs = 10L
|
||||
gattReleaseDelayMs = 0L
|
||||
},
|
||||
runUpdate = {
|
||||
withTimeoutOrNull(3000L) {
|
||||
handler.startUpdate(release, hardware, BLE_TARGET, states::add, firmwareUri)
|
||||
}
|
||||
},
|
||||
) {
|
||||
assertEquals(
|
||||
"n",
|
||||
radioController.lastSetDeviceAddress,
|
||||
"Silent preflight must still disconnect mesh service",
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Valid BLE MAC target so startUpdate routes through the BLE OTA preflight path.
|
||||
const val BLE_TARGET = "AA:BB:CC:DD:EE:FF"
|
||||
const val WIFI_TARGET = "192.168.1.33"
|
||||
const val DISCOVERED_WIFI_TARGET = "192.168.1.44"
|
||||
const val BLE_CONFIRMATION = "Rebooting to BLE OTA"
|
||||
const val WIFI_CONFIRMATION = "Rebooting to WiFi OTA"
|
||||
const val CONFIRMATION_DELAY_MS = 50L
|
||||
const val BLE_CONNECT_ATTEMPTS = 5
|
||||
const val WIFI_CONNECT_ATTEMPTS = 10
|
||||
const val TEST_WIFI_READINESS_DELAY_MS = 1234L
|
||||
}
|
||||
}
|
||||
|
||||
private data class PreflightFixture(
|
||||
val handler: Esp32OtaUpdateHandler,
|
||||
val radioController: FakeRadioController,
|
||||
val states: MutableList<FirmwareUpdateState>,
|
||||
)
|
||||
|
||||
private class TestEsp32OtaUpdateEnvironment : Esp32OtaUpdateEnvironment {
|
||||
override var otaPreflightTimeoutMs: Long = 5_000L
|
||||
override var otaTransportRetryDelayMs: Long = 2_000L
|
||||
override var wifiOtaReadinessDelayMs: Long = 8_000L
|
||||
override var gattReleaseDelayMs: Long = 1_000L
|
||||
override var wifiDiscoveryEnabled: Boolean = false
|
||||
|
||||
// Fully qualified: an unqualified `delay(it)` would resolve to this class's own override (member functions win
|
||||
// over imported top-level functions), producing infinite recursion through delayBlock → StackOverflowError.
|
||||
var delayBlock: suspend (Long) -> Unit = { kotlinx.coroutines.delay(it) }
|
||||
var bleTransportFactory: ((String) -> UnifiedOtaProtocol)? = null
|
||||
var wifiTransportFactory: ((String) -> UnifiedOtaProtocol)? = null
|
||||
var discoverWifiOtaDeviceBlock: suspend () -> String? = { null }
|
||||
|
||||
override suspend fun delay(milliseconds: Long) {
|
||||
delayBlock(milliseconds)
|
||||
}
|
||||
|
||||
override fun createBleTransport(
|
||||
bleScanner: org.meshtastic.core.ble.BleScanner,
|
||||
bleConnectionFactory: org.meshtastic.core.ble.BleConnectionFactory,
|
||||
address: String,
|
||||
dispatcher: CoroutineDispatcher,
|
||||
): UnifiedOtaProtocol =
|
||||
bleTransportFactory?.invoke(address) ?: BleOtaTransport(bleScanner, bleConnectionFactory, address, dispatcher)
|
||||
|
||||
override fun createWifiTransport(deviceIp: String): UnifiedOtaProtocol =
|
||||
wifiTransportFactory?.invoke(deviceIp) ?: WifiOtaTransport(deviceIp)
|
||||
|
||||
override suspend fun discoverWifiOtaDevice(): String? = discoverWifiOtaDeviceBlock.invoke()
|
||||
}
|
||||
|
||||
private class FakeOtaTransport(
|
||||
private val name: String,
|
||||
private val events: MutableList<String>,
|
||||
private val connectResult: Result<Unit> = Result.success(Unit),
|
||||
) : UnifiedOtaProtocol {
|
||||
override suspend fun connect(): Result<Unit> {
|
||||
events += "connect:$name"
|
||||
return connectResult
|
||||
}
|
||||
|
||||
override suspend fun startOta(
|
||||
sizeBytes: Long,
|
||||
sha256Hash: String,
|
||||
onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit,
|
||||
): Result<Unit> {
|
||||
events += "start:$name"
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun streamFirmware(
|
||||
data: ByteArray,
|
||||
chunkSize: Int,
|
||||
onProgress: suspend (Float) -> Unit,
|
||||
): Result<Unit> {
|
||||
events += "stream:$name"
|
||||
onProgress(1f)
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun close() {
|
||||
events += "close:$name"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.firmware.ota
|
||||
|
||||
import io.ktor.network.sockets.InetSocketAddress
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class WifiOtaDiscoveryTest {
|
||||
|
||||
@Test
|
||||
fun `senderHost extracts host from UDP sender address`() {
|
||||
val address = InetSocketAddress("192.168.1.44", WifiOtaTransport.DEFAULT_PORT)
|
||||
|
||||
assertEquals("192.168.1.44", WifiOtaDiscovery.senderHost(address))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Meshtastic OTA discovery beacon is identified`() {
|
||||
assertTrue(WifiOtaDiscovery.isOtaDiscoveryBeacon("Meshtastic_ABCD 1.2.3".encodeToByteArray()))
|
||||
assertTrue(WifiOtaDiscovery.isOtaDiscoveryBeacon("Meshtastic_01af v1.2-5-g8a2b3c".encodeToByteArray()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unrelated port 3232 datagrams are ignored`() {
|
||||
assertFalse(WifiOtaDiscovery.isOtaDiscoveryBeacon(ByteArray(0)))
|
||||
assertFalse(WifiOtaDiscovery.isOtaDiscoveryBeacon("OK".encodeToByteArray()))
|
||||
assertFalse(WifiOtaDiscovery.isOtaDiscoveryBeacon("0 3232 1024 abcdef".encodeToByteArray()))
|
||||
assertFalse(WifiOtaDiscovery.isOtaDiscoveryBeacon("Meshtastic_OTA 1.2.3".encodeToByteArray()))
|
||||
assertFalse(WifiOtaDiscovery.isOtaDiscoveryBeacon("Meshtastic_ABCD".encodeToByteArray()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user