Skip to content

Commit eb16a52

Browse files
committed
fix(firmware): Polish ESP32 OTA connection hardening
1 parent d26aeb4 commit eb16a52

12 files changed

Lines changed: 444 additions & 245 deletions

File tree

core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import kotlinx.coroutines.SupervisorJob
2222
import org.koin.core.annotation.Single
2323
import org.meshtastic.core.common.util.handledLaunch
2424
import org.meshtastic.core.common.util.ioDispatcher
25+
import org.meshtastic.core.model.util.isOtaStatusNotification
2526
import org.meshtastic.core.repository.FromRadioPacketHandler
2627
import org.meshtastic.core.repository.LockdownCoordinator
2728
import org.meshtastic.core.repository.MeshConfigFlowManager
@@ -176,14 +177,3 @@ class FromRadioPacketHandlerImpl(
176177
}
177178
}
178179
}
179-
180-
private const val OTA_KEYWORD = "OTA"
181-
private const val OTA_CONFIRM_PREFIX = "Rebooting to"
182-
private val OTA_REJECTION_PREFIXES = listOf("Cannot start OTA", "OTA Loader", "Unable to switch to the OTA partition")
183-
184-
internal fun ClientNotification.isOtaStatusNotification(): Boolean {
185-
val message = message.trim()
186-
if (message.isBlank() || !message.contains(OTA_KEYWORD)) return false
187-
188-
return message.startsWith(OTA_CONFIRM_PREFIX) || OTA_REJECTION_PREFIXES.any { message.startsWith(it) }
189-
}

core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import dev.mokkery.answering.returns
2121
import dev.mokkery.every
2222
import dev.mokkery.mock
2323
import dev.mokkery.verify
24+
import org.meshtastic.core.model.util.isOtaStatusNotification
2425
import org.meshtastic.core.repository.MeshConfigFlowManager
2526
import org.meshtastic.core.repository.MeshConfigHandler
2627
import org.meshtastic.core.repository.MqttManager

core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
package org.meshtastic.core.model.util
2020

2121
import org.meshtastic.proto.Channel
22+
import org.meshtastic.proto.ClientNotification
2223
import org.meshtastic.proto.Config
2324
import org.meshtastic.proto.MeshPacket
2425
import org.meshtastic.proto.ModuleConfig
@@ -139,3 +140,12 @@ fun getInitials(fullName: String): String {
139140
}
140141

141142
fun String.withoutEmojis(): String = filterNot { char -> char.isSurrogate() }
143+
144+
private const val OTA_KEYWORD = "OTA"
145+
146+
/**
147+
* Broad OTA-status detector matching the firmware update preflight gate's criterion: any ClientNotification whose
148+
* message contains "OTA" (e.g. "Rebooting to BLE OTA", "Cannot start OTA: ...") is consumed by the firmware update flow
149+
* and suppressed as a generic alert. Shared so the UI and data layers do not duplicate the matcher.
150+
*/
151+
fun ClientNotification.isOtaStatusNotification(): Boolean = message.isNotBlank() && message.contains(OTA_KEYWORD)

core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import org.meshtastic.core.model.TracerouteMapAvailability
4747
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
4848
import org.meshtastic.core.model.service.TracerouteResponse
4949
import org.meshtastic.core.model.util.dispatchMeshtasticUri
50+
import org.meshtastic.core.model.util.isOtaStatusNotification
5051
import org.meshtastic.core.navigation.DeepLinkRouter
5152
import org.meshtastic.core.repository.EventFirmwareRepository
5253
import org.meshtastic.core.repository.FirmwareReleaseRepository
@@ -258,8 +259,7 @@ class UIViewModel(
258259
.onEach { notification ->
259260
// OTA status notifications (e.g. "Rebooting to WiFi OTA") are consumed by the firmware update
260261
// preflight gate — don't show a popup dialog for them.
261-
val msg = notification.message.trim()
262-
if (msg.contains("OTA") && (msg.startsWith("Rebooting to") || msg.startsWith("Cannot start OTA"))) {
262+
if (notification.isOtaStatusNotification()) {
263263
return@onEach
264264
}
265265
val isCompromised = notification.low_entropy_key != null || notification.duplicated_public_key != null

core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModelImportSummaryTest.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
package org.meshtastic.core.ui.viewmodel
1818

1919
import org.meshtastic.core.common.util.CommonUri
20+
import org.meshtastic.core.model.util.isOtaStatusNotification
21+
import org.meshtastic.proto.ClientNotification
2022
import kotlin.test.Test
2123
import kotlin.test.assertFalse
2224
import kotlin.test.assertTrue
@@ -46,4 +48,21 @@ class UIViewModelImportSummaryTest {
4648

4749
assertTrue(summary.contains("hasFragment=false"))
4850
}
51+
52+
@Test
53+
fun ota_status_notifications_are_suppressed() {
54+
assertTrue(ClientNotification(message = "Rebooting to WiFi OTA").isOtaStatusNotification())
55+
assertTrue(ClientNotification(message = "Rebooting to BLE OTA").isOtaStatusNotification())
56+
assertTrue(
57+
ClientNotification(message = "Cannot start OTA: OTA Loader partition not found.").isOtaStatusNotification(),
58+
)
59+
assertTrue(ClientNotification(message = "OTA Loader does not support WiFi").isOtaStatusNotification())
60+
assertTrue(ClientNotification(message = "Unable to switch to the OTA partition.").isOtaStatusNotification())
61+
}
62+
63+
@Test
64+
fun non_ota_status_notifications_are_not_suppressed() {
65+
assertFalse(ClientNotification(message = "Low battery").isOtaStatusNotification())
66+
assertFalse(ClientNotification(message = "Key verification requested").isOtaStatusNotification())
67+
}
4968
}

feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,21 @@ import kotlin.time.Duration
4343
import kotlin.time.Duration.Companion.milliseconds
4444
import kotlin.time.Duration.Companion.seconds
4545

46+
private const val MAC_OCTET_COUNT = 6
47+
private const val MAC_OUI_OCTETS = 3
48+
private const val MASKED_MAC_MIDDLE = "**:**"
49+
private const val INVALID_MAC_LABEL = "<invalid-mac>"
50+
51+
/**
52+
* Masks a BLE MAC address for logging/UI-facing exception text: preserves the OUI (first 3 octets) and last octet for
53+
* diagnostics while hiding the unique middle portion. Privacy rule: never log raw PII.
54+
*/
55+
internal fun maskMac(address: String): String {
56+
val octets = address.split(":")
57+
if (octets.size != MAC_OCTET_COUNT || octets.any { it.length != 2 }) return INVALID_MAC_LABEL
58+
return "${octets.take(MAC_OUI_OCTETS).joinToString(":")}:$MASKED_MAC_MIDDLE:${octets.last()}"
59+
}
60+
4661
/** BLE transport implementation for ESP32 Unified OTA protocol using Kable. */
4762
class BleOtaTransport(
4863
private val scanner: BleScanner,
@@ -85,7 +100,7 @@ class BleOtaTransport(
85100
val device =
86101
scanForOtaDevice()
87102
?: throw OtaProtocolException.ConnectionFailed(
88-
"Device not found at address $address. " +
103+
"Device not found at address ${maskMac(address)}. " +
89104
"Ensure the device has rebooted into OTA mode and is advertising.",
90105
)
91106

@@ -101,12 +116,14 @@ class BleOtaTransport(
101116
if (finalState is BleConnectionState.Disconnected) {
102117
Logger.w { "BLE OTA: Failed to connect to ${maskMac(device.address)} (state=$finalState)" }
103118
throw OtaProtocolException.ConnectionFailed(
104-
"Failed to connect to device at address ${device.address}",
119+
"Failed to connect to device at address ${maskMac(device.address)}",
105120
)
106121
}
107122
} catch (@Suppress("SwallowedException") e: kotlinx.coroutines.TimeoutCancellationException) {
108123
Logger.w { "BLE OTA: Timed out waiting to connect to ${maskMac(device.address)}. Error: ${e.message}" }
109-
throw OtaProtocolException.Timeout("Timed out connecting to device at address ${device.address}")
124+
throw OtaProtocolException.Timeout(
125+
"Timed out connecting to device at address ${maskMac(device.address)}",
126+
)
110127
}
111128

112129
Logger.i { "BLE OTA: Connected to ${maskMac(device.address)}, discovering services..." }
@@ -295,13 +312,6 @@ class BleOtaTransport(
295312
OtaProtocolException.TransferFailed("Transfer failed: ${error.message}")
296313
}
297314

298-
/**
299-
* Masks a BLE MAC address for logging: preserves the OUI (first 3 octets) and last octet for diagnostics while
300-
* hiding the unique middle portion. Privacy rule: never log raw PII.
301-
*/
302-
private fun maskMac(address: String): String =
303-
address.takeIf { it.length >= 17 }?.let { "${it.substring(0, 8)}:**:**:**:${it.substring(15)}" } ?: address
304-
305315
override suspend fun close() {
306316
try {
307317
bleConnection.disconnect()

feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt

Lines changed: 78 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ private const val OTA_KEYWORD = "OTA"
8787
// — keeps an IPv6 WiFi target (which also has colons) from being misrouted to the BLE path.
8888
private val MAC_ADDRESS_REGEX = Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")
8989

90+
private data class TransportFactoryResolution(
91+
val factory: () -> UnifiedOtaProtocol,
92+
val readinessAlreadyWaited: Boolean,
93+
)
94+
9095
internal fun isBleMacAddress(target: String): Boolean = MAC_ADDRESS_REGEX.matches(target)
9196

9297
/**
@@ -115,6 +120,7 @@ class Esp32OtaUpdateHandler(
115120
internal var delayFn: suspend (Long) -> Unit = { delay(it) }
116121
internal var bleTransportFactoryOverride: ((String) -> UnifiedOtaProtocol)? = null
117122
internal var wifiTransportFactoryOverride: ((String) -> UnifiedOtaProtocol)? = null
123+
internal var wifiOtaDiscoveryOverride: (suspend () -> String?)? = null
118124

119125
/** Entry point for FirmwareUpdateHandler interface. Routes to BLE (target is a MAC) or WiFi (anything else). */
120126
override suspend fun startUpdate(
@@ -320,49 +326,71 @@ class Esp32OtaUpdateHandler(
320326
}
321327
}
322328

323-
@Suppress("ThrowsCount") // CancellationException rethrow + two post-confirm failure paths
324329
private suspend fun connectToDevice(
325330
transportFactory: () -> UnifiedOtaProtocol,
326331
attempts: Int,
327332
rebootMode: Int,
328333
postConfirmReadinessDelayMs: Long,
329334
updateState: (FirmwareUpdateState) -> Unit,
330335
): UnifiedOtaProtocol {
331-
// Show "waiting for reboot" state before first connection attempt
332336
updateState(
333337
FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_waiting_reboot))),
334338
)
335339

336-
if (postConfirmReadinessDelayMs > 0L) {
337-
Logger.i {
338-
"ESP32 OTA: Waiting ${postConfirmReadinessDelayMs}ms for ${otaModeName(rebootMode)} OTA service"
339-
}
340-
delayFn(postConfirmReadinessDelayMs)
341-
}
340+
val resolution = resolvePostConfirmTransportFactory(rebootMode, transportFactory, updateState)
341+
waitForPostConfirmReadiness(rebootMode, postConfirmReadinessDelayMs, resolution.readinessAlreadyWaited)
342+
343+
return runTransportConnectRetries(resolution.factory, attempts, rebootMode, updateState)
344+
}
342345

346+
private suspend fun resolvePostConfirmTransportFactory(
347+
rebootMode: Int,
348+
transportFactory: () -> UnifiedOtaProtocol,
349+
updateState: (FirmwareUpdateState) -> Unit,
350+
): TransportFactoryResolution {
343351
// In production WiFi mode the device may have picked up a different DHCP lease after rebooting into the OTA
344352
// loader. Listen for the loader's UDP discovery broadcast and, if one arrives, redirect the TCP transport at
345-
// the discovered IP. Skipped when a transport override is installed (tests inject fake transports).
346-
val effectiveTransportFactory: () -> UnifiedOtaProtocol =
347-
if (rebootMode == REBOOT_MODE_WIFI && wifiTransportFactoryOverride == null) {
348-
updateState(
349-
FirmwareUpdateState.Processing(
350-
ProgressState(UiText.Resource(Res.string.firmware_update_searching_device)),
351-
),
352-
)
353-
val discoveredIp = WifiOtaDiscovery.discoverOtaDevice()
354-
if (discoveredIp != null) {
355-
Logger.i { "ESP32 OTA: Using UDP-discovered OTA device IP $discoveredIp for TCP transport" }
356-
val factory: () -> UnifiedOtaProtocol = { WifiOtaTransport(discoveredIp) }
357-
factory
358-
} else {
359-
Logger.i { "ESP32 OTA: No UDP discovery broadcast received; falling back to configured device IP" }
360-
transportFactory
353+
// the discovered IP. Skipped for transport-only test overrides unless a discovery override is installed.
354+
if (
355+
rebootMode != REBOOT_MODE_WIFI || (wifiTransportFactoryOverride != null && wifiOtaDiscoveryOverride == null)
356+
) {
357+
return TransportFactoryResolution(factory = transportFactory, readinessAlreadyWaited = false)
358+
}
359+
360+
updateState(
361+
FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_searching_device))),
362+
)
363+
val discoverOtaDevice = wifiOtaDiscoveryOverride ?: { WifiOtaDiscovery.discoverOtaDevice() }
364+
val discoveredIp = discoverOtaDevice()
365+
val factory: () -> UnifiedOtaProtocol =
366+
if (discoveredIp != null) {
367+
val discoveredFactory: () -> UnifiedOtaProtocol = {
368+
wifiTransportFactoryOverride?.invoke(discoveredIp) ?: WifiOtaTransport(discoveredIp)
361369
}
370+
Logger.i { "ESP32 OTA: Using UDP-discovered OTA device for TCP transport" }
371+
discoveredFactory
362372
} else {
373+
Logger.i { "ESP32 OTA: No UDP discovery broadcast received; falling back to configured device IP" }
363374
transportFactory
364375
}
376+
// Only treat the readiness window as already-spent when discovery actually resolved a device — its 15 s
377+
// listening window covers the loader's reboot+DHCP+TCP-server bring-up. On a null result (timeout / bind
378+
// failure) the device has not yet been heard from, so the caller's 8 s readiness margin still applies.
379+
return TransportFactoryResolution(factory = factory, readinessAlreadyWaited = discoveredIp != null)
380+
}
381+
382+
private suspend fun waitForPostConfirmReadiness(rebootMode: Int, delayMs: Long, readinessAlreadyWaited: Boolean) {
383+
if (delayMs <= 0L || readinessAlreadyWaited) return
384+
Logger.i { "ESP32 OTA: Waiting ${delayMs}ms for ${otaModeName(rebootMode)} OTA service" }
385+
delayFn(delayMs)
386+
}
365387

388+
private suspend fun runTransportConnectRetries(
389+
transportFactory: () -> UnifiedOtaProtocol,
390+
attempts: Int,
391+
rebootMode: Int,
392+
updateState: (FirmwareUpdateState) -> Unit,
393+
): UnifiedOtaProtocol {
366394
var connectedTransport: UnifiedOtaProtocol? = null
367395
val result =
368396
retryWithDelay(
@@ -376,40 +404,38 @@ class Esp32OtaUpdateHandler(
376404
)
377405
},
378406
) {
379-
val transport = effectiveTransportFactory()
380-
val connectResult =
381-
try {
382-
transport.connect()
383-
} catch (e: CancellationException) {
384-
closeFailedTransport(transport)
385-
throw e
386-
} catch (@Suppress("TooGenericExceptionCaught") e: Throwable) {
387-
Result.failure(e)
388-
}
389-
390-
if (connectResult.isSuccess) {
391-
connectedTransport = transport
392-
connectResult
393-
} else {
394-
val error = connectResult.exceptionOrNull()
395-
if (error is CancellationException) {
396-
closeFailedTransport(transport)
397-
throw error
398-
}
399-
Logger.w(error) {
400-
"ESP32 OTA: ${otaModeName(
401-
rebootMode,
402-
)} connection attempt failed; closing transport before retry"
403-
}
404-
closeFailedTransport(transport)
405-
Result.failure(error ?: OtaProtocolException.ConnectionFailed("Connection attempt failed"))
406-
}
407+
val transport = transportFactory()
408+
val connectResult = connectTransportAttempt(transport, rebootMode)
409+
connectResult.onSuccess { connectedTransport = transport }
407410
}
408411

409412
result.getOrElse { cause -> throw postConfirmConnectionFailed(rebootMode, attempts, cause) }
410413
return connectedTransport ?: throw postConfirmConnectionFailed(rebootMode, attempts, null)
411414
}
412415

416+
private suspend fun connectTransportAttempt(transport: UnifiedOtaProtocol, rebootMode: Int): Result<Unit> {
417+
val connectResult =
418+
try {
419+
transport.connect()
420+
} catch (e: CancellationException) {
421+
closeFailedTransport(transport)
422+
throw e
423+
} catch (@Suppress("TooGenericExceptionCaught") e: Throwable) {
424+
Result.failure(e)
425+
}
426+
427+
val error = connectResult.exceptionOrNull() ?: return connectResult
428+
if (error is CancellationException) {
429+
closeFailedTransport(transport)
430+
throw error
431+
}
432+
Logger.w(error) {
433+
"ESP32 OTA: ${otaModeName(rebootMode)} connection attempt failed; closing transport before retry"
434+
}
435+
closeFailedTransport(transport)
436+
return Result.failure(error)
437+
}
438+
413439
private suspend fun closeFailedTransport(transport: UnifiedOtaProtocol) {
414440
withContext(NonCancellable) {
415441
try {

0 commit comments

Comments
 (0)