@@ -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.
8888private 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+
9095internal 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