From 2a60480bd982cb126217d9c2a663cd6630407a73 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 14 Jan 2026 21:22:30 -0600 Subject: [PATCH] feat: Add ESP32 Unified OTA update support (#4095) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> Co-authored-by: Ben Meadors --- .../assets/device_bootloader_ota_quirks.json | 155 ++++- .../mesh/repository/radio/TCPInterface.kt | 101 ++-- .../mesh/service/MeshActionHandler.kt | 11 + .../geeksville/mesh/service/MeshService.kt | 6 + .../java/com/geeksville/mesh/service/Fakes.kt | 3 + .../repository/DeviceHardwareRepository.kt | 1 + .../database/entity/FirmwareReleaseEntity.kt | 4 +- .../core/model/BootloaderOtaQuirk.kt | 8 +- .../org/meshtastic/core/model/Capabilities.kt | 4 + .../meshtastic/core/model/DeviceHardware.kt | 4 +- .../meshtastic/core/service/IMeshService.aidl | 7 + .../composeResources/values/strings.xml | 43 +- feature/firmware/README.md | 83 +++ feature/firmware/build.gradle.kts | 5 + .../feature/firmware/FirmwareDfuService.kt | 21 +- .../feature/firmware/FirmwareFileHandler.kt | 123 ++-- .../feature/firmware/FirmwareRetriever.kt | 122 ++++ .../feature/firmware/FirmwareUpdateActions.kt | 30 + .../feature/firmware/FirmwareUpdateHandler.kt | 43 ++ .../feature/firmware/FirmwareUpdateManager.kt | 100 ++++ .../feature/firmware/FirmwareUpdateScreen.kt | 557 +++++++++++------- .../feature/firmware/FirmwareUpdateState.kt | 22 +- .../firmware/FirmwareUpdateViewModel.kt | 364 +++++++----- .../feature/firmware/NordicDfuHandler.kt | 250 ++++++++ .../feature/firmware/UpdateHandler.kt | 221 ------- .../meshtastic/feature/firmware/UsbManager.kt | 61 ++ .../feature/firmware/UsbUpdateHandler.kt | 102 ++++ .../feature/firmware/ota/BleOtaTransport.kt | 362 ++++++++++++ .../firmware/ota/Esp32OtaUpdateHandler.kt | 343 +++++++++++ .../feature/firmware/ota/FirmwareHashUtil.kt | 48 ++ .../firmware/ota/UnifiedOtaProtocol.kt | 145 +++++ .../feature/firmware/ota/WifiOtaTransport.kt | 291 +++++++++ .../feature/firmware/FirmwareRetrieverTest.kt | 174 ++++++ .../firmware/ota/BleOtaTransportTest.kt | 99 ++++ .../firmware/ota/Esp32OtaUpdateHandlerTest.kt | 95 +++ .../firmware/ota/UnifiedOtaProtocolTest.kt | 89 +++ .../feature/settings/SettingsScreen.kt | 4 +- .../feature/settings/SettingsViewModel.kt | 17 +- .../feature/settings/radio/RadioConfig.kt | 7 +- gradle/libs.versions.toml | 2 + 40 files changed, 3410 insertions(+), 717 deletions(-) create mode 100644 feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt create mode 100644 feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateActions.kt create mode 100644 feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateHandler.kt create mode 100644 feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateManager.kt create mode 100644 feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/NordicDfuHandler.kt delete mode 100644 feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/UpdateHandler.kt create mode 100644 feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/UsbManager.kt create mode 100644 feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/UsbUpdateHandler.kt create mode 100644 feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt create mode 100644 feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt create mode 100644 feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/FirmwareHashUtil.kt create mode 100644 feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/UnifiedOtaProtocol.kt create mode 100644 feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt create mode 100644 feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/FirmwareRetrieverTest.kt create mode 100644 feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportTest.kt create mode 100644 feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandlerTest.kt create mode 100644 feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/UnifiedOtaProtocolTest.kt diff --git a/app/src/main/assets/device_bootloader_ota_quirks.json b/app/src/main/assets/device_bootloader_ota_quirks.json index 92b156834..e41042b9a 100644 --- a/app/src/main/assets/device_bootloader_ota_quirks.json +++ b/app/src/main/assets/device_bootloader_ota_quirks.json @@ -4,21 +4,164 @@ "hwModel": 18, "hwModelSlug": "NANO_G2_ULTRA", "requiresBootloaderUpgradeForOta": true, - "infoUrl": "https://github.com/RAKWireless/WisBlock/tree/master/bootloader/RAK4630/Latest/WisCore_RAK4631_Bootloader" + "infoUrl": "https://meshtastic.org/docs/getting-started/flashing-firmware/nrf52/update-nrf52-bootloader/" }, { "hwModel": 9, "hwModelSlug": "RAK4631", "requiresBootloaderUpgradeForOta": true, - "infoUrl": "https://github.com/RAKWireless/WisBlock/tree/master/bootloader/RAK4630/Latest/WisCore_RAK4631_Bootloader" + "infoUrl": "https://meshtastic.org/docs/getting-started/flashing-firmware/nrf52/update-nrf52-bootloader/" }, { "hwModel": 96, "hwModelSlug": "NOMADSTAR_METEOR_PRO", "requiresBootloaderUpgradeForOta": true, - "infoUrl": "https://github.com/RAKWireless/WisBlock/tree/master/bootloader/RAK4630/Latest/WisCore_RAK4631_Bootloader" + "infoUrl": "https://meshtastic.org/docs/getting-started/flashing-firmware/nrf52/update-nrf52-bootloader/" + }, + { + "hwModel": 12, + "hwModelSlug": "LILYGO_TBEAM_S3_CORE", + "supportsUnifiedOta": true + }, + { + "hwModel": 16, + "hwModelSlug": "TLORA_T3_S3", + "supportsUnifiedOta": true + }, + { + "hwModel": 28, + "hwModelSlug": "SENSELORA_S3", + "supportsUnifiedOta": true + }, + { + "hwModel": 43, + "hwModelSlug": "HELTEC_V3", + "supportsUnifiedOta": true + }, + { + "hwModel": 44, + "hwModelSlug": "HELTEC_WSL_V3", + "supportsUnifiedOta": true + }, + { + "hwModel": 45, + "hwModelSlug": "BETAFPV_2400_TX", + "supportsUnifiedOta": true + }, + { + "hwModel": 46, + "hwModelSlug": "BETAFPV_900_NANO_TX", + "supportsUnifiedOta": true + }, + { + "hwModel": 48, + "hwModelSlug": "HELTEC_WIRELESS_TRACKER", + "supportsUnifiedOta": true + }, + { + "hwModel": 49, + "hwModelSlug": "HELTEC_WIRELESS_PAPER", + "supportsUnifiedOta": true + }, + { + "hwModel": 50, + "hwModelSlug": "T_DECK", + "supportsUnifiedOta": true + }, + { + "hwModel": 51, + "hwModelSlug": "T_WATCH_S3", + "supportsUnifiedOta": true + }, + { + "hwModel": 52, + "hwModelSlug": "PICOMPUTER_S3", + "supportsUnifiedOta": true + }, + { + "hwModel": 53, + "hwModelSlug": "HELTEC_HT62", + "supportsUnifiedOta": true + }, + { + "hwModel": 54, + "hwModelSlug": "EBYTE_ESP32_S3", + "supportsUnifiedOta": true + }, + { + "hwModel": 55, + "hwModelSlug": "ESP32_S3_PICO", + "supportsUnifiedOta": true + }, + { + "hwModel": 56, + "hwModelSlug": "CHATTER_2", + "supportsUnifiedOta": true + }, + { + "hwModel": 57, + "hwModelSlug": "HELTEC_WIRELESS_PAPER_V1_0", + "supportsUnifiedOta": true + }, + { + "hwModel": 58, + "hwModelSlug": "HELTEC_WIRELESS_TRACKER_V1_0", + "supportsUnifiedOta": true + }, + { + "hwModel": 59, + "hwModelSlug": "UNPHONE", + "supportsUnifiedOta": true + }, + { + "hwModel": 61, + "hwModelSlug": "CDEBYTE_EORA_S3", + "supportsUnifiedOta": true + }, + { + "hwModel": 64, + "hwModelSlug": "RADIOMASTER_900_BANDIT_NANO", + "supportsUnifiedOta": true + }, + { + "hwModel": 65, + "hwModelSlug": "HELTEC_CAPSULE_SENSOR_V3", + "supportsUnifiedOta": true + }, + { + "hwModel": 66, + "hwModelSlug": "HELTEC_VISION_MASTER_T190", + "supportsUnifiedOta": true + }, + { + "hwModel": 67, + "hwModelSlug": "HELTEC_VISION_MASTER_E213", + "supportsUnifiedOta": true + }, + { + "hwModel": 68, + "hwModelSlug": "HELTEC_VISION_MASTER_E290", + "supportsUnifiedOta": true + }, + { + "hwModel": 70, + "hwModelSlug": "SENSECAP_INDICATOR", + "supportsUnifiedOta": true + }, + { + "hwModel": 74, + "hwModelSlug": "RADIOMASTER_900_BANDIT", + "supportsUnifiedOta": true + }, + { + "hwModel": 107, + "hwModelSlug": "THINKNODE_M5", + "supportsUnifiedOta": true + }, + { + "hwModel": 110, + "hwModelSlug": "HELTEC_V4", + "supportsUnifiedOta": true } ] -} - - +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt index 3b587705b..9fbb9c69f 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt @@ -151,69 +151,76 @@ constructor( val attemptStart = System.currentTimeMillis() Logger.i { "[$address] TCP connection attempt starting..." } - val (host, port) = - address.split(":", limit = 2).let { it[0] to (it.getOrNull(1)?.toIntOrNull() ?: SERVICE_PORT) } + val parts = address.split(":", limit = 2) + val host = parts[0] + val port = parts.getOrNull(1)?.toIntOrNull() ?: SERVICE_PORT + Logger.i { "[$address] Parsed address. Host: $host, Port: $port" } Logger.d { "[$address] Resolving host '$host' and connecting to port $port..." } - Socket(InetAddress.getByName(host), port).use { socket -> - socket.tcpNoDelay = true - socket.soTimeout = SOCKET_TIMEOUT - this@TCPInterface.socket = socket + try { + Socket(InetAddress.getByName(host), port).use { socket -> + socket.tcpNoDelay = true + socket.soTimeout = SOCKET_TIMEOUT + this@TCPInterface.socket = socket - val connectTime = System.currentTimeMillis() - attemptStart - connectionStartTime = System.currentTimeMillis() - Logger.i { - "[$address] TCP socket connected in ${connectTime}ms - " + - "Local: ${socket.localSocketAddress}, Remote: ${socket.remoteSocketAddress}" - } + val connectTime = System.currentTimeMillis() - attemptStart + connectionStartTime = System.currentTimeMillis() + Logger.i { + "[$address] TCP socket connected in ${connectTime}ms - " + + "Local: ${socket.localSocketAddress}, Remote: ${socket.remoteSocketAddress}" + } - BufferedOutputStream(socket.getOutputStream()).use { outputStream -> - outStream = outputStream + BufferedOutputStream(socket.getOutputStream()).use { outputStream -> + outStream = outputStream - BufferedInputStream(socket.getInputStream()).use { inputStream -> - super.connect() + BufferedInputStream(socket.getInputStream()).use { inputStream -> + super.connect() - retryCount = 1 - backoffDelay = MIN_BACKOFF_MILLIS + retryCount = 1 + backoffDelay = MIN_BACKOFF_MILLIS - var timeoutCount = 0 - while (timeoutCount < SOCKET_RETRIES) { - try { // close after 90s of inactivity - val c = inputStream.read() - if (c == -1) { - Logger.w { - "[$address] TCP got EOF on stream after $packetsReceived packets received" + var timeoutCount = 0 + while (timeoutCount < SOCKET_RETRIES) { + try { // close after 90s of inactivity + val c = inputStream.read() + if (c == -1) { + Logger.w { + "[$address] TCP got EOF on stream after $packetsReceived packets received" + } + break + } else { + timeoutCount = 0 + packetsReceived++ + bytesReceived++ + readChar(c.toByte()) } - break - } else { - timeoutCount = 0 - packetsReceived++ - bytesReceived++ - readChar(c.toByte()) - } - } catch (ex: SocketTimeoutException) { - timeoutCount++ - timeoutEvents++ - if (timeoutCount % TIMEOUT_LOG_INTERVAL == 0) { - Logger.d { - "[$address] TCP socket timeout count: $timeoutCount/$SOCKET_RETRIES " + - "(total timeouts: $timeoutEvents)" + } catch (ex: SocketTimeoutException) { + timeoutCount++ + timeoutEvents++ + if (timeoutCount % TIMEOUT_LOG_INTERVAL == 0) { + Logger.d { + "[$address] TCP socket timeout count: $timeoutCount/$SOCKET_RETRIES " + + "(total timeouts: $timeoutEvents)" + } } + // Ignore and start another read } - // Ignore and start another read } - } - if (timeoutCount >= SOCKET_RETRIES) { - val inactivityMs = SOCKET_RETRIES * SOCKET_TIMEOUT - Logger.w { - "[$address] TCP closing connection due to $SOCKET_RETRIES consecutive timeouts " + - "(${inactivityMs}ms of inactivity)" + if (timeoutCount >= SOCKET_RETRIES) { + val inactivityMs = SOCKET_RETRIES * SOCKET_TIMEOUT + Logger.w { + "[$address] TCP closing connection due to $SOCKET_RETRIES consecutive timeouts " + + "(${inactivityMs}ms of inactivity)" + } } } } + onDeviceDisconnect(false) } - onDeviceDisconnect(false) + } catch (e: IOException) { + Logger.e(e) { "[$address] Error connecting to $host:$port" } + throw e } } } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt index f634450c4..4a5d9b7f8 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt @@ -18,6 +18,7 @@ package com.geeksville.mesh.service import com.geeksville.mesh.concurrent.handledLaunch import com.geeksville.mesh.util.ignoreException +import com.google.protobuf.ByteString import dagger.Lazy import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -302,6 +303,16 @@ constructor( commandSender.sendAdmin(destNum, requestId) { rebootSeconds = DEFAULT_REBOOT_DELAY } } + fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) { + val otaMode = AdminProtos.OTAMode.forNumber(mode) ?: AdminProtos.OTAMode.NO_REBOOT_OTA + val otaEventBuilder = AdminProtos.AdminMessage.OTAEvent.newBuilder() + otaEventBuilder.rebootOtaMode = otaMode + if (hash != null) { + otaEventBuilder.otaHash = ByteString.copyFrom(hash) + } + commandSender.sendAdmin(destNum, requestId) { otaRequest = otaEventBuilder.build() } + } + fun handleRequestFactoryReset(requestId: Int, destNum: Int) { commandSender.sendAdmin(destNum, requestId) { factoryResetDevice = 1 } } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index 2f7cad15f..8a19e05b0 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -52,6 +52,7 @@ import org.meshtastic.core.service.ServiceRepository import javax.inject.Inject @AndroidEntryPoint +@Suppress("TooManyFunctions", "LargeClass") class MeshService : Service() { @Inject lateinit var radioInterfaceService: RadioInterfaceService @@ -342,5 +343,10 @@ class MeshService : Service() { override fun requestTelemetry(requestId: Int, destNum: Int, type: Int) = toRemoteExceptions { router.actionHandler.handleRequestTelemetry(requestId, destNum, type) } + + override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) = + toRemoteExceptions { + router.actionHandler.handleRequestRebootOta(requestId, destNum, mode, hash) + } } } diff --git a/app/src/test/java/com/geeksville/mesh/service/Fakes.kt b/app/src/test/java/com/geeksville/mesh/service/Fakes.kt index e243e0d27..e9cb68320 100644 --- a/app/src/test/java/com/geeksville/mesh/service/Fakes.kt +++ b/app/src/test/java/com/geeksville/mesh/service/Fakes.kt @@ -87,6 +87,7 @@ class FakeMeshServiceNotifications : MeshServiceNotifications { message: String, isBroadcast: Boolean, channelName: String?, + isSilent: Boolean, ) {} override suspend fun updateWaypointNotification( @@ -94,6 +95,7 @@ class FakeMeshServiceNotifications : MeshServiceNotifications { name: String, message: String, waypointId: Int, + isSilent: Boolean, ) {} override suspend fun updateReactionNotification( @@ -102,6 +104,7 @@ class FakeMeshServiceNotifications : MeshServiceNotifications { emoji: String, isBroadcast: Boolean, channelName: String?, + isSilent: Boolean, ) {} override fun showAlertNotification(contactKey: String, name: String, alert: String) {} diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt index 30cc4c31a..9ab83d4c4 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt @@ -179,6 +179,7 @@ constructor( } base.copy( requiresBootloaderUpgradeForOta = quirk.requiresBootloaderUpgradeForOta, + supportsUnifiedOta = quirk.supportsUnifiedOta, bootloaderInfoUrl = quirk.infoUrl, ) } else { diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/FirmwareReleaseEntity.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/FirmwareReleaseEntity.kt index 0808250b4..bc11ae942 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/FirmwareReleaseEntity.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/FirmwareReleaseEntity.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-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 @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.core.database.entity import androidx.room.ColumnInfo @@ -73,4 +72,5 @@ fun FirmwareRelease.asDeviceVersion(): DeviceVersion = DeviceVersion(id.substrin enum class FirmwareReleaseType { STABLE, ALPHA, + LOCAL, } diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt index d793a3414..f3263729f 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-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 @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.core.model import kotlinx.serialization.SerialName @@ -31,6 +30,11 @@ data class BootloaderOtaQuirk( * one-time bootloader upgrade (typically via USB) before DFU updates from the app work. */ @SerialName("requiresBootloaderUpgradeForOta") val requiresBootloaderUpgradeForOta: Boolean = false, + /** + * Indicates that the device supports the ESP32 Unified OTA protocol. When true, the app will use the unified OTA + * handler instead of Nordic DFU. + */ + @SerialName("supportsUnifiedOta") val supportsUnifiedOta: Boolean = false, /** Optional URL pointing to documentation on how to update the bootloader. */ @SerialName("infoUrl") val infoUrl: String? = null, ) diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/Capabilities.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/Capabilities.kt index 550be2519..da40b7ee1 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/Capabilities.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/Capabilities.kt @@ -55,4 +55,8 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl /** Support for sharing contact information via QR codes. Supported since firmware v2.6.8. */ val supportsQrCodeSharing: Boolean get() = isSupported("2.6.8") + + /** Support for ESP32 Unified OTA. Supported since firmware v2.7.18. */ + val supportsEsp32Ota: Boolean + get() = isSupported("2.7.18") } diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/DeviceHardware.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/DeviceHardware.kt index c5b7fa253..7ae971a94 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/DeviceHardware.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/DeviceHardware.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-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 @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.core.model import kotlinx.serialization.Serializable @@ -39,6 +38,7 @@ data class DeviceHardware( val requiresBootloaderUpgradeForOta: Boolean? = null, /** Optional URL pointing to documentation for upgrading the bootloader. */ val bootloaderInfoUrl: String? = null, + val supportsUnifiedOta: Boolean = false, val supportLevel: Int? = null, val tags: List? = null, ) diff --git a/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl b/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl index 0015176e8..543af76ac 100644 --- a/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl +++ b/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl @@ -182,4 +182,11 @@ interface IMeshService { /// Send request for telemetry to nodeNum void requestTelemetry(in int requestId, in int destNum, in int type); + + /** + * Tell the node to reboot into OTA mode for firmware update via BLE or WiFi (ESP32 only) + * mode is 1 for BLE, 2 for WiFi + * hash is the 32-byte firmware SHA256 hash (optional, can be null) + */ + void requestRebootOta(in int requestId, in int destNum, in int mode, in byte []hash); } diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index 2cb2f8e04..73bdbc3f1 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -562,7 +562,7 @@ Triple Click Ad Hoc Ping Time Zone LED Heartbeat - Display + Device Display Screen on for Carousel interval Compass north top @@ -1022,17 +1022,20 @@ Checking for updates... Device: %1$s Currently Installed: %1$s - Latest Release: %1$s + Update To: %1$s Stable Alpha Note: This will temporarily disconnect your device during the update. - Downloading firmware... %1$d% + Downloading firmware... %1$d%% Error: %1$s Retry Update Successful! Done Starting DFU... - Updating... %1$s% + Updating... %1$s + Enabling DFU mode... + Validating firmware... + Disconnecting... Unknown hardware model: %1$d Connected device is not a valid BLE device or address is unknown (%1$s). No device connected @@ -1046,6 +1049,8 @@ Almost there... This might take a minute... Select Local File + Local File + Source: Local File Unknown remote release Update Warning You are about to flash new firmware to your device. This process carries risks.\n\n• Ensure your device is charged.\n• Keep the device close to your phone.\n• Do not close the app during the update.\n\nVerify you have selected the correct firmware for your hardware. @@ -1058,9 +1063,39 @@ Flashing device, please wait... USB File Transfer BLE OTA + WiFi OTA Update via %1$s Select DFU USB Drive Your device has rebooted into DFU mode and should appear as a USB drive (e.g., RAK4631).\n\nWhen the file picker opens, please select the root of that drive to save the firmware file. + Verifying update... + Verification timed out. Device did not reconnect in time. + Waiting for device to reconnect... + Target: %1$s + Release Notes + Unknown error + Local update failed + DFU Error: %1$s + DFU Aborted + Node user information is missing. + Battery too low (%1$d%%). Please charge your device before updating. + Could not retrieve firmware file. + Nordic DFU Update failed + USB Update failed + Firmware hash rejected. Device may require hash provisioning or bootloader update. + OTA update failed: %1$s + Loading firmware... + Waiting for device to reboot into OTA mode... + Connecting to device (attempt %1$d/%2$d)... + Checking device version... + Starting OTA update... + Uploading firmware... + Uploading firmware... %1$d%% (%2$s) + Rebooting device... + Firmware Update + Firmware update status + Erasing... + Back + Unset Always On diff --git a/feature/firmware/README.md b/feature/firmware/README.md index 11b444873..9a1929822 100644 --- a/feature/firmware/README.md +++ b/feature/firmware/README.md @@ -109,3 +109,86 @@ classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000; + +## Firmware Update System + +The `:feature:firmware` module provides a unified interface for updating Meshtastic devices across different platforms and connection types. + +### Supported Platforms & Methods + +Meshtastic-Android supports three primary firmware update flows: + +#### 1. ESP32 Unified OTA (WiFi & BLE) +Used for modern ESP32 devices (e.g., Heltec V3, T-Beam S3). This method utilizes the **Unified OTA Protocol**, which enables high-speed transfers over TCP (port 3232) or BLE. The BLE transport uses the **Nordic Semiconductor Kotlin-BLE-Library** for architectural consistency with the rest of the application. + +**Key Features:** +- **Pre-shared Hash Verification**: The app sends the firmware SHA256 hash in an initial `AdminMessage` trigger. The device stores this in NVS and verifies the incoming stream against it. +- **Connection Retry**: Robust logic to wait for the device to reboot and start the OTA listener. + +```mermaid +sequenceDiagram + participant App as Android App + participant Radio as Mesh Node (Admin) + participant OTA as ESP32 OTA Mode + + Note over App: Phase 1: Preparation + App->>App: Calculate SHA256 Hash + + Note over App, Radio: Phase 2: Trigger Reboot + App->>Radio: AdminMessage (ota_request = mode + hash) + Radio->>Radio: Store Hash in NVS & Reboot + + Note over App, OTA: Phase 3: Connection & Update + App->>OTA: Connect (TCP:3232 or BLE) + App->>OTA: Handshake & Version Check + App->>OTA: Start OTA (Size + Hash) + loop Streaming + App->>OTA: Stream Data Chunks + OTA-->>App: ACK + end + App->>OTA: REBOOT Command +``` + +#### 2. nRF52 BLE DFU +The standard update method for nRF52-based devices (e.g., RAK4631). It leverages the **Nordic Semiconductor DFU library**. + +```mermaid +sequenceDiagram + participant App as Android App + participant Radio as Mesh Node + participant DFU as nRF DFU Bootloader + + App->>Radio: Trigger DFU Mode + Radio->>Radio: Reboot into Bootloader + App->>DFU: Connect via BLE + App->>DFU: Initialize DFU Transaction + loop Transfer + App->>DFU: Stream ZIP Segments + DFU-->>App: Progress + end + DFU->>DFU: Verify, Swap & Reboot +``` + +#### 3. USB / UF2 (RP2040, nRF52, STM32) +For devices supporting USB Mass Storage updates. The app triggers the device into its native bootloader mode, then guides the user to save the UF2 firmware file to the mounted drive. + +```mermaid +sequenceDiagram + participant App as Android App + participant Radio as Mesh Node + participant USB as USB Mass Storage + + App->>Radio: rebootToDfu() + Radio->>Radio: Mounts as MESH_DRIVE + App->>App: Prompt User to Save UF2 + App->>USB: Write firmware.uf2 + USB->>USB: Auto-Flash & Reboot +``` + +### Key Classes + +- `UpdateHandler.kt`: Entry point for choosing the correct handler. +- `Esp32OtaUpdateHandler.kt`: Orchestrates the Unified OTA flow. +- `WifiOtaTransport.kt`: Implements the TCP/UDP transport logic for ESP32. +- `BleOtaTransport.kt`: Implements the BLE transport logic for ESP32 using the Nordic BLE library. +- `FirmwareRetriever.kt`: Handles downloading and extracting firmware assets (ZIP/BIN/UF2). diff --git a/feature/firmware/build.gradle.kts b/feature/firmware/build.gradle.kts index 746fab955..6145bfd25 100644 --- a/feature/firmware/build.gradle.kts +++ b/feature/firmware/build.gradle.kts @@ -37,6 +37,7 @@ plugins { alias(libs.plugins.meshtastic.android.library) alias(libs.plugins.meshtastic.android.library.compose) alias(libs.plugins.meshtastic.hilt) + alias(libs.plugins.kover) } configure { namespace = "org.meshtastic.feature.firmware" } @@ -73,4 +74,8 @@ dependencies { androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.test.ext.junit) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) } diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareDfuService.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareDfuService.kt index 6c69dd3d7..c376fa64a 100644 --- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareDfuService.kt +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareDfuService.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-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 @@ -14,22 +14,35 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.firmware import android.app.Activity import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context +import kotlinx.coroutines.runBlocking import no.nordicsemi.android.dfu.DfuBaseService +import org.jetbrains.compose.resources.getString import org.meshtastic.core.model.BuildConfig +import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.firmware_update_channel_description +import org.meshtastic.core.strings.firmware_update_channel_name class FirmwareDfuService : DfuBaseService() { override fun onCreate() { val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Using runBlocking here is acceptable as onCreate is a lifecycle method + // and we need localized strings for the notification channel. + val (channelName, channelDesc) = + runBlocking { + getString(Res.string.firmware_update_channel_name) to + getString(Res.string.firmware_update_channel_description) + } + val channel = - NotificationChannel(NOTIFICATION_CHANNEL_DFU, "Firmware Update", NotificationManager.IMPORTANCE_LOW).apply { - description = "Firmware update status" + NotificationChannel(NOTIFICATION_CHANNEL_DFU, channelName, NotificationManager.IMPORTANCE_LOW).apply { + description = channelDesc setShowBadge(false) } manager.createNotificationChannel(channel) diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt index b5b346949..981d7e5cc 100644 --- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-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 @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.firmware import android.content.Context @@ -117,61 +116,97 @@ constructor( targetFile } - suspend fun extractFirmware(zipFile: File, hardware: DeviceHardware, fileExtension: String): File? = - withContext(Dispatchers.IO) { - val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug } - if (target.isEmpty()) return@withContext null + suspend fun extractFirmware( + zipFile: File, + hardware: DeviceHardware, + fileExtension: String, + preferredFilename: String? = null, + ): File? = withContext(Dispatchers.IO) { + val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug } + if (target.isEmpty() && preferredFilename == null) return@withContext null - val targetLowerCase = target.lowercase() - val matchingEntries = mutableListOf>() + val targetLowerCase = target.lowercase() + val preferredFilenameLower = preferredFilename?.lowercase() + val matchingEntries = mutableListOf>() - if (!tempDir.exists()) tempDir.mkdirs() + if (!tempDir.exists()) tempDir.mkdirs() - ZipInputStream(zipFile.inputStream()).use { zipInput -> + ZipInputStream(zipFile.inputStream()).use { zipInput -> + var entry = zipInput.nextEntry + while (entry != null) { + val name = entry.name.lowercase() + val entryFileName = File(name).name + + val isMatch = + if (preferredFilenameLower != null) { + entryFileName == preferredFilenameLower + } else { + !entry.isDirectory && isValidFirmwareFile(name, targetLowerCase, fileExtension) + } + + if (isMatch) { + val outFile = File(tempDir, entryFileName) + FileOutputStream(outFile).use { output -> zipInput.copyTo(output) } + matchingEntries.add(entry to outFile) + + if (preferredFilenameLower != null) { + return@withContext outFile + } + } + entry = zipInput.nextEntry + } + } + matchingEntries.minByOrNull { it.first.name.length }?.second + } + + suspend fun extractFirmware( + uri: Uri, + hardware: DeviceHardware, + fileExtension: String, + preferredFilename: String? = null, + ): File? = withContext(Dispatchers.IO) { + val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug } + if (target.isEmpty() && preferredFilename == null) return@withContext null + + val targetLowerCase = target.lowercase() + val preferredFilenameLower = preferredFilename?.lowercase() + val matchingEntries = mutableListOf>() + + if (!tempDir.exists()) tempDir.mkdirs() + + try { + val inputStream = context.contentResolver.openInputStream(uri) ?: return@withContext null + ZipInputStream(inputStream).use { zipInput -> var entry = zipInput.nextEntry while (entry != null) { val name = entry.name.lowercase() - if (!entry.isDirectory && isValidFirmwareFile(name, targetLowerCase, fileExtension)) { - val outFile = File(tempDir, File(name).name) + val entryFileName = File(name).name + + val isMatch = + if (preferredFilenameLower != null) { + entryFileName == preferredFilenameLower + } else { + !entry.isDirectory && isValidFirmwareFile(name, targetLowerCase, fileExtension) + } + + if (isMatch) { + val outFile = File(tempDir, entryFileName) FileOutputStream(outFile).use { output -> zipInput.copyTo(output) } matchingEntries.add(entry to outFile) + + if (preferredFilenameLower != null) { + return@withContext outFile + } } entry = zipInput.nextEntry } } - matchingEntries.minByOrNull { it.first.name.length }?.second - } - - suspend fun extractFirmware(uri: Uri, hardware: DeviceHardware, fileExtension: String): File? = - withContext(Dispatchers.IO) { - val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug } - if (target.isEmpty()) return@withContext null - - val targetLowerCase = target.lowercase() - val matchingEntries = mutableListOf>() - - if (!tempDir.exists()) tempDir.mkdirs() - - try { - val inputStream = context.contentResolver.openInputStream(uri) ?: return@withContext null - ZipInputStream(inputStream).use { zipInput -> - var entry = zipInput.nextEntry - while (entry != null) { - val name = entry.name.lowercase() - if (!entry.isDirectory && isValidFirmwareFile(name, targetLowerCase, fileExtension)) { - val outFile = File(tempDir, File(name).name) - FileOutputStream(outFile).use { output -> zipInput.copyTo(output) } - matchingEntries.add(entry to outFile) - } - entry = zipInput.nextEntry - } - } - } catch (e: IOException) { - Logger.w(e) { "Failed to extract firmware from URI" } - return@withContext null - } - matchingEntries.minByOrNull { it.first.name.length }?.second + } catch (e: IOException) { + Logger.w(e) { "Failed to extract firmware from URI" } + return@withContext null } + matchingEntries.minByOrNull { it.first.name.length }?.second + } private fun isValidFirmwareFile(filename: String, target: String, fileExtension: String): Boolean { val regex = Regex(".*[\\-_]${Regex.escape(target)}[\\-_\\.].*") diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt new file mode 100644 index 000000000..86dbbf311 --- /dev/null +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.firmware + +import co.touchlab.kermit.Logger +import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.model.DeviceHardware +import java.io.File +import javax.inject.Inject + +/** Retrieves firmware files, either by direct download or by extracting from a release asset. */ +class FirmwareRetriever @Inject constructor(private val fileHandler: FirmwareFileHandler) { + suspend fun retrieveOtaFirmware( + release: FirmwareRelease, + hardware: DeviceHardware, + onProgress: (Float) -> Unit, + ): File? = retrieve( + release = release, + hardware = hardware, + onProgress = onProgress, + fileSuffix = "-ota.zip", + internalFileExtension = ".zip", + ) + + suspend fun retrieveUsbFirmware( + release: FirmwareRelease, + hardware: DeviceHardware, + onProgress: (Float) -> Unit, + ): File? = retrieve( + release = release, + hardware = hardware, + onProgress = onProgress, + fileSuffix = ".uf2", + internalFileExtension = ".uf2", + ) + + suspend fun retrieveEsp32Firmware( + release: FirmwareRelease, + hardware: DeviceHardware, + onProgress: (Float) -> Unit, + ): File? { + if (hardware.supportsUnifiedOta) { + val mcu = hardware.architecture.replace("-", "") + val otaFilename = "mt-$mcu-ota.bin" + retrieve( + release = release, + hardware = hardware, + onProgress = onProgress, + fileSuffix = ".bin", + internalFileExtension = ".bin", + preferredFilename = otaFilename, + ) + ?.let { + return it + } + } + + return retrieve( + release = release, + hardware = hardware, + onProgress = onProgress, + fileSuffix = ".bin", + internalFileExtension = ".bin", + ) + } + + private suspend fun retrieve( + release: FirmwareRelease, + hardware: DeviceHardware, + onProgress: (Float) -> Unit, + fileSuffix: String, + internalFileExtension: String, + preferredFilename: String? = null, + ): File? { + val version = release.id.removePrefix("v") + val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug } + val filename = preferredFilename ?: "firmware-$target-$version$fileSuffix" + val directUrl = + "https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-$version/$filename" + + if (fileHandler.checkUrlExists(directUrl)) { + try { + fileHandler.downloadFile(directUrl, filename, onProgress)?.let { + return it + } + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.w(e) { "Direct download for $filename failed, falling back to release zip" } + } + } + + // Fallback to downloading the full release zip and extracting + val zipUrl = getDeviceFirmwareUrl(release.zipUrl, hardware.architecture) + val downloadedZip = fileHandler.downloadFile(zipUrl, "firmware_release.zip", onProgress) + return downloadedZip?.let { + fileHandler.extractFirmware(it, hardware, internalFileExtension, preferredFilename) + } + } + + private fun getDeviceFirmwareUrl(url: String, targetArch: String): String { + val knownArchs = listOf("esp32-s3", "esp32-c3", "esp32-c6", "nrf52840", "rp2040", "stm32", "esp32") + for (arch in knownArchs) { + if (url.contains(arch, ignoreCase = true)) { + return url.replace(arch, targetArch.lowercase(), ignoreCase = true) + } + } + return url + } +} diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateActions.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateActions.kt new file mode 100644 index 000000000..3d41f7142 --- /dev/null +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateActions.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.firmware + +import org.meshtastic.core.database.entity.FirmwareReleaseType + +data class FirmwareUpdateActions( + val onReleaseTypeSelect: (FirmwareReleaseType) -> Unit, + val onStartUpdate: () -> Unit, + val onPickFile: () -> Unit, + val onSaveFile: (String) -> Unit, + val onRetry: () -> Unit, + val onCancel: () -> Unit, + val onDone: () -> Unit, + val onDismissBootloaderWarning: () -> Unit, +) diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateHandler.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateHandler.kt new file mode 100644 index 000000000..df5ce6e78 --- /dev/null +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateHandler.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.firmware + +import android.net.Uri +import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.model.DeviceHardware +import java.io.File + +/** Common interface for all firmware update handlers (BLE DFU, ESP32 OTA, USB). */ +interface FirmwareUpdateHandler { + /** + * Start the firmware update process. + * + * @param release The firmware release to install + * @param hardware The target device hardware + * @param target The target identifier (e.g., Bluetooth address, IP address, or empty for USB) + * @param updateState Callback to report back state changes + * @param firmwareUri Optional URI for a local firmware file (bypasses download) + * @return The downloaded/extracted firmware file, or null if it was a local file or update finished + */ + suspend fun startUpdate( + release: FirmwareRelease, + hardware: DeviceHardware, + target: String, + updateState: (FirmwareUpdateState) -> Unit, + firmwareUri: Uri? = null, + ): File? +} diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateManager.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateManager.kt new file mode 100644 index 000000000..ea178dac3 --- /dev/null +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateManager.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.firmware + +import android.net.Uri +import kotlinx.coroutines.flow.Flow +import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.core.prefs.radio.RadioPrefs +import org.meshtastic.core.prefs.radio.isBle +import org.meshtastic.core.prefs.radio.isSerial +import org.meshtastic.core.prefs.radio.isTcp +import org.meshtastic.feature.firmware.ota.Esp32OtaUpdateHandler +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +/** Orchestrates the firmware update process by choosing the correct handler. */ +@Singleton +class FirmwareUpdateManager +@Inject +constructor( + private val radioPrefs: RadioPrefs, + private val nordicDfuHandler: NordicDfuHandler, + private val usbUpdateHandler: UsbUpdateHandler, + private val esp32OtaUpdateHandler: Esp32OtaUpdateHandler, +) { + + /** Start the update process based on the current connection and hardware. */ + suspend fun startUpdate( + release: FirmwareRelease, + hardware: DeviceHardware, + address: String, + updateState: (FirmwareUpdateState) -> Unit, + firmwareUri: Uri? = null, + ): File? { + val handler = getHandler(hardware) + val target = getTarget(address) + + return handler.startUpdate( + release = release, + hardware = hardware, + target = target, + updateState = updateState, + firmwareUri = firmwareUri, + ) + } + + fun dfuProgressFlow(): Flow = nordicDfuHandler.progressFlow() + + private fun getHandler(hardware: DeviceHardware): FirmwareUpdateHandler = when { + radioPrefs.isSerial() -> usbUpdateHandler + radioPrefs.isBle() -> { + if (isEsp32Architecture(hardware.architecture)) { + esp32OtaUpdateHandler + } else { + nordicDfuHandler + } + } + radioPrefs.isTcp() -> { + if (isEsp32Architecture(hardware.architecture)) { + esp32OtaUpdateHandler + } else { + // Should be handled/validated before calling startUpdate + error("WiFi OTA only supported for ESP32 devices") + } + } + else -> error("Unknown connection type for firmware update") + } + + private fun getTarget(address: String): String = when { + radioPrefs.isSerial() -> "" + radioPrefs.isBle() -> address + radioPrefs.isTcp() -> extractIpFromAddress(radioPrefs.devAddr) ?: "" + else -> "" + } + + private fun isEsp32Architecture(architecture: String): Boolean = architecture.startsWith("esp32", ignoreCase = true) + + private fun extractIpFromAddress(address: String?): String? = + if (address != null && address.startsWith("t") && address.length > 1) { + address.substring(1) + } else { + null + } +} diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt index a932be981..49c7fe66c 100644 --- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-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 @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - @file:Suppress("TooManyFunctions") @file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @@ -23,13 +22,12 @@ package org.meshtastic.feature.firmware import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -47,14 +45,13 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.Dangerous -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.SystemUpdate import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.rounded.Bluetooth import androidx.compose.material.icons.rounded.Usb +import androidx.compose.material.icons.rounded.Wifi import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -78,7 +75,6 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -86,16 +82,20 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import coil3.compose.AsyncImage import coil3.request.ImageRequest +import coil3.request.crossfade import com.mikepenz.markdown.m3.Markdown import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource @@ -103,6 +103,7 @@ import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.database.entity.FirmwareReleaseType import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.back import org.meshtastic.core.strings.cancel import org.meshtastic.core.strings.chirpy import org.meshtastic.core.strings.dont_show_again_for_device @@ -117,44 +118,53 @@ import org.meshtastic.core.strings.firmware_update_disclaimer_title import org.meshtastic.core.strings.firmware_update_disconnect_warning import org.meshtastic.core.strings.firmware_update_do_not_close import org.meshtastic.core.strings.firmware_update_done -import org.meshtastic.core.strings.firmware_update_downloading import org.meshtastic.core.strings.firmware_update_error import org.meshtastic.core.strings.firmware_update_hang_tight import org.meshtastic.core.strings.firmware_update_keep_device_close import org.meshtastic.core.strings.firmware_update_latest +import org.meshtastic.core.strings.firmware_update_local_file import org.meshtastic.core.strings.firmware_update_method_detail import org.meshtastic.core.strings.firmware_update_rak4631_bootloader_hint +import org.meshtastic.core.strings.firmware_update_release_notes import org.meshtastic.core.strings.firmware_update_retry import org.meshtastic.core.strings.firmware_update_save_dfu_file import org.meshtastic.core.strings.firmware_update_select_file +import org.meshtastic.core.strings.firmware_update_source_local import org.meshtastic.core.strings.firmware_update_stable import org.meshtastic.core.strings.firmware_update_success import org.meshtastic.core.strings.firmware_update_taking_a_while +import org.meshtastic.core.strings.firmware_update_target import org.meshtastic.core.strings.firmware_update_title import org.meshtastic.core.strings.firmware_update_unknown_release import org.meshtastic.core.strings.firmware_update_usb_bootloader_warning import org.meshtastic.core.strings.firmware_update_usb_instruction_text import org.meshtastic.core.strings.firmware_update_usb_instruction_title +import org.meshtastic.core.strings.firmware_update_verification_failed +import org.meshtastic.core.strings.firmware_update_verifying +import org.meshtastic.core.strings.firmware_update_waiting_reconnect import org.meshtastic.core.strings.i_know_what_i_m_doing import org.meshtastic.core.strings.learn_more import org.meshtastic.core.strings.okay import org.meshtastic.core.strings.save -@OptIn(ExperimentalMaterial3Api::class) +private const val CYCLE_DELAY_MS = 4500L + @Composable +@Suppress("LongMethod") fun FirmwareUpdateScreen( navController: NavController, modifier: Modifier = Modifier, viewModel: FirmwareUpdateViewModel = hiltViewModel(), ) { - val state by viewModel.state.collectAsState() - val selectedReleaseType by viewModel.selectedReleaseType.collectAsState() + val state by viewModel.state.collectAsStateWithLifecycle() + val selectedReleaseType by viewModel.selectedReleaseType.collectAsStateWithLifecycle() + val deviceHardware by viewModel.deviceHardware.collectAsStateWithLifecycle() + val currentVersion by viewModel.currentFirmwareVersion.collectAsStateWithLifecycle() + val selectedRelease by viewModel.selectedRelease.collectAsStateWithLifecycle() - val getZipFileLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> - uri?.let { viewModel.startUpdateFromFile(it) } - } - val getUf2FileLauncher = + var showExitConfirmation by remember { mutableStateOf(false) } + + val getFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> uri?.let { viewModel.startUpdateFromFile(it) } } @@ -166,30 +176,67 @@ fun FirmwareUpdateScreen( uri?.let { viewModel.saveDfuFile(it) } } - val shouldKeepScreenOn = shouldKeepFirmwareScreenOn(state) + val actions = + remember(viewModel, navController, state) { + FirmwareUpdateActions( + onReleaseTypeSelect = viewModel::setReleaseType, + onStartUpdate = viewModel::startUpdate, + onPickFile = { + if (state is FirmwareUpdateState.Ready) { + val readyState = state as FirmwareUpdateState.Ready + if ( + readyState.updateMethod is FirmwareUpdateMethod.Ble || + readyState.updateMethod is FirmwareUpdateMethod.Wifi + ) { + getFileLauncher.launch("*/*") + } else if (readyState.updateMethod is FirmwareUpdateMethod.Usb) { + getFileLauncher.launch("*/*") + } + } + }, + onSaveFile = { fileName -> saveFileLauncher.launch(fileName) }, + onRetry = viewModel::checkForUpdates, + onCancel = { showExitConfirmation = true }, + onDone = { navController.navigateUp() }, + onDismissBootloaderWarning = viewModel::dismissBootloaderWarningForCurrentDevice, + ) + } - KeepScreenOn(shouldKeepScreenOn) + KeepScreenOn(shouldKeepFirmwareScreenOn(state)) + + androidx.activity.compose.BackHandler(enabled = shouldKeepFirmwareScreenOn(state)) { showExitConfirmation = true } + + if (showExitConfirmation) { + AlertDialog( + onDismissRequest = { showExitConfirmation = false }, + title = { Text(stringResource(Res.string.firmware_update_disclaimer_title)) }, + text = { Text(stringResource(Res.string.firmware_update_disconnect_warning)) }, + confirmButton = { + TextButton( + onClick = { + showExitConfirmation = false + viewModel.cancelUpdate() + navController.navigateUp() + }, + ) { + Text(stringResource(Res.string.firmware_update_retry)) // Use "Cancel & Exit" if available + } + }, + dismissButton = { + TextButton(onClick = { showExitConfirmation = false }) { Text(stringResource(Res.string.back)) } + }, + ) + } FirmwareUpdateScaffold( modifier = modifier, navController = navController, state = state, selectedReleaseType = selectedReleaseType, - onReleaseTypeSelect = viewModel::setReleaseType, - onStartUpdate = viewModel::startUpdate, - onPickFile = { - if (state is FirmwareUpdateState.Ready) { - if ((state as FirmwareUpdateState.Ready).updateMethod is FirmwareUpdateMethod.Ble) { - getZipFileLauncher.launch("application/zip") - } else if ((state as FirmwareUpdateState.Ready).updateMethod is FirmwareUpdateMethod.Usb) { - getUf2FileLauncher.launch("*/*") - } - } - }, - onSaveFile = { fileName -> saveFileLauncher.launch(fileName) }, - onRetry = viewModel::checkForUpdates, - onDone = { navController.navigateUp() }, - onDismissBootloaderWarning = viewModel::dismissBootloaderWarningForCurrentDevice, + actions = actions, + deviceHardware = deviceHardware, + currentVersion = currentVersion, + selectedRelease = selectedRelease, ) } @@ -198,13 +245,10 @@ private fun FirmwareUpdateScaffold( navController: NavController, state: FirmwareUpdateState, selectedReleaseType: FirmwareReleaseType, - onReleaseTypeSelect: (FirmwareReleaseType) -> Unit, - onStartUpdate: () -> Unit, - onPickFile: () -> Unit, - onSaveFile: (String) -> Unit, - onRetry: () -> Unit, - onDone: () -> Unit, - onDismissBootloaderWarning: () -> Unit, + actions: FirmwareUpdateActions, + deviceHardware: DeviceHardware?, + currentVersion: String?, + selectedRelease: FirmwareRelease?, modifier: Modifier = Modifier, ) { Scaffold( @@ -214,24 +258,46 @@ private fun FirmwareUpdateScaffold( title = { Text(stringResource(Res.string.firmware_update_title)) }, navigationIcon = { IconButton(onClick = { navController.navigateUp() }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back)) } }, ) }, ) { padding -> - Box(modifier = Modifier.padding(padding).fillMaxSize(), contentAlignment = Alignment.Center) { - FirmwareUpdateContent( - state = state, - selectedReleaseType = selectedReleaseType, - onReleaseTypeSelect = onReleaseTypeSelect, - onStartUpdate = onStartUpdate, - onPickFile = onPickFile, - onSaveFile = onSaveFile, - onRetry = onRetry, - onDone = onDone, - onDismissBootloaderWarning = onDismissBootloaderWarning, - ) + Column( + modifier = + Modifier.padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp) + .animateContentSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (deviceHardware != null) { + Spacer(Modifier.height(16.dp)) + AnimatedVisibility( + visible = + state is FirmwareUpdateState.Ready || + state is FirmwareUpdateState.Idle || + state is FirmwareUpdateState.Checking, + ) { + Column { + ReleaseTypeSelector(selectedReleaseType, actions.onReleaseTypeSelect) + Spacer(Modifier.height(16.dp)) + } + } + DeviceInfoCard( + deviceHardware = deviceHardware, + release = selectedRelease, + currentFirmwareVersion = currentVersion, + selectedReleaseType = selectedReleaseType, + ) + Spacer(Modifier.height(16.dp)) + } + + Box(contentAlignment = Alignment.TopCenter) { + FirmwareUpdateContent(state = state, selectedReleaseType = selectedReleaseType, actions = actions) + } } } } @@ -240,6 +306,7 @@ private fun shouldKeepFirmwareScreenOn(state: FirmwareUpdateState): Boolean = wh is FirmwareUpdateState.Downloading, is FirmwareUpdateState.Processing, is FirmwareUpdateState.Updating, + is FirmwareUpdateState.Verifying, -> true else -> false @@ -249,25 +316,12 @@ private fun shouldKeepFirmwareScreenOn(state: FirmwareUpdateState): Boolean = wh private fun FirmwareUpdateContent( state: FirmwareUpdateState, selectedReleaseType: FirmwareReleaseType, - onReleaseTypeSelect: (FirmwareReleaseType) -> Unit, - onStartUpdate: () -> Unit, - onPickFile: () -> Unit, - onSaveFile: (String) -> Unit, - onRetry: () -> Unit, - onDone: () -> Unit, - onDismissBootloaderWarning: () -> Unit, + actions: FirmwareUpdateActions, ) { - val modifier = - if (state is FirmwareUpdateState.Ready) { - Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp) - } else { - Modifier.padding(24.dp) - } - Column( - modifier = modifier, + modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.Top, content = { when (state) { is FirmwareUpdateState.Idle, @@ -275,47 +329,59 @@ private fun FirmwareUpdateContent( -> CheckingState() is FirmwareUpdateState.Ready -> - ReadyState( - state = state, - selectedReleaseType = selectedReleaseType, - onReleaseTypeSelect = onReleaseTypeSelect, - onStartUpdate = onStartUpdate, - onPickFile = onPickFile, - onDismissBootloaderWarning = onDismissBootloaderWarning, - ) + ReadyState(state = state, selectedReleaseType = selectedReleaseType, actions = actions) - is FirmwareUpdateState.Downloading -> DownloadingState(state) - is FirmwareUpdateState.Processing -> ProcessingState(state.message) - is FirmwareUpdateState.Updating -> UpdatingState(state) - is FirmwareUpdateState.Error -> ErrorState(error = state.error, onRetry = onRetry) - is FirmwareUpdateState.Success -> SuccessState(onDone = onDone) - is FirmwareUpdateState.AwaitingFileSave -> AwaitingFileSaveState(state, onSaveFile) + is FirmwareUpdateState.Downloading -> + ProgressContent(state.progressState, onCancel = actions.onCancel, isDownloading = true) + + is FirmwareUpdateState.Processing -> ProgressContent(state.progressState, onCancel = actions.onCancel) + + is FirmwareUpdateState.Updating -> + ProgressContent(state.progressState, onCancel = actions.onCancel, isUpdating = true) + + is FirmwareUpdateState.Verifying -> VerifyingState() + is FirmwareUpdateState.VerificationFailed -> + VerificationFailedState(onRetry = actions.onStartUpdate, onIgnore = actions.onDone) + is FirmwareUpdateState.Error -> ErrorState(error = state.error, onRetry = actions.onRetry) + is FirmwareUpdateState.Success -> SuccessState(onDone = actions.onDone) + is FirmwareUpdateState.AwaitingFileSave -> AwaitingFileSaveState(state, actions.onSaveFile) } }, ) } @Composable -private fun ColumnScope.CheckingState() { +private fun VerifyingState() { + CircularWavyProgressIndicator(modifier = Modifier.size(64.dp)) + Spacer(Modifier.height(24.dp)) + Text(stringResource(Res.string.firmware_update_verifying), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + Text( + stringResource(Res.string.firmware_update_waiting_reconnect), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + CyclingMessages() +} + +@Composable +private fun CheckingState() { CircularWavyProgressIndicator(modifier = Modifier.size(64.dp)) Spacer(Modifier.height(24.dp)) Text(stringResource(Res.string.firmware_update_checking), style = MaterialTheme.typography.bodyLarge) } -@OptIn(ExperimentalMaterial3Api::class) @Composable @Suppress("LongMethod") -private fun ColumnScope.ReadyState( +private fun ReadyState( state: FirmwareUpdateState.Ready, selectedReleaseType: FirmwareReleaseType, - onReleaseTypeSelect: (FirmwareReleaseType) -> Unit, - onStartUpdate: () -> Unit, - onPickFile: () -> Unit, - onDismissBootloaderWarning: () -> Unit, + actions: FirmwareUpdateActions, ) { var showDisclaimer by remember { mutableStateOf(false) } - var pendingAction by remember { mutableStateOf<(() -> Unit)?>(null) } val device = state.deviceHardware + val haptic = LocalHapticFeedback.current if (showDisclaimer) { DisclaimerDialog( @@ -323,26 +389,38 @@ private fun ColumnScope.ReadyState( onDismissRequest = { showDisclaimer = false }, onConfirm = { showDisclaimer = false - pendingAction?.invoke() + if (selectedReleaseType == FirmwareReleaseType.LOCAL) { + actions.onPickFile() + } else { + actions.onStartUpdate() + } }, ) } - DeviceInfoCard(device, state.release, state.currentFirmwareVersion) - if (state.showBootloaderWarning) { + BootloaderWarningCard(deviceHardware = device, onDismissForDevice = actions.onDismissBootloaderWarning) Spacer(Modifier.height(16.dp)) - BootloaderWarningCard(deviceHardware = device, onDismissForDevice = onDismissBootloaderWarning) } - if (state.release != null) { - ReleaseTypeSelector(selectedReleaseType, onReleaseTypeSelect) - Spacer(Modifier.height(16.dp)) - ReleaseNotesCard(state.release.releaseNotes) - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(16.dp)) + + if (selectedReleaseType == FirmwareReleaseType.LOCAL) { Button( onClick = { - pendingAction = onStartUpdate + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + showDisclaimer = true + }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Icon(Icons.Default.Folder, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(stringResource(Res.string.firmware_update_select_file)) + } + } else if (state.release != null) { + Button( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) showDisclaimer = true }, modifier = Modifier.fillMaxWidth().height(56.dp), @@ -352,6 +430,7 @@ private fun ColumnScope.ReadyState( when (state.updateMethod) { FirmwareUpdateMethod.Ble -> Icons.Rounded.Bluetooth FirmwareUpdateMethod.Usb -> Icons.Rounded.Usb + FirmwareUpdateMethod.Wifi -> Icons.Rounded.Wifi else -> Icons.Default.SystemUpdate }, contentDescription = null, @@ -364,19 +443,8 @@ private fun ColumnScope.ReadyState( ), ) } - Spacer(Modifier.height(16.dp)) - } - - OutlinedButton( - onClick = { - pendingAction = onPickFile - showDisclaimer = true - }, - modifier = Modifier.fillMaxWidth().height(56.dp), - ) { - Icon(Icons.Default.Folder, contentDescription = null) - Spacer(Modifier.width(8.dp)) - Text(stringResource(Res.string.firmware_update_select_file)) + Spacer(Modifier.height(24.dp)) + ReleaseNotesCard(state.release.releaseNotes) } } @@ -386,7 +454,7 @@ private fun DisclaimerDialog(updateMethod: FirmwareUpdateMethod, onDismissReques onDismissRequest = onDismissRequest, title = { Text(stringResource(Res.string.firmware_update_disclaimer_title)) }, text = { - Column(horizontalAlignment = Alignment.CenterHorizontally) { + Column(modifier = Modifier.animateContentSize(), horizontalAlignment = Alignment.CenterHorizontally) { Text(stringResource(Res.string.firmware_update_disclaimer_text)) Spacer(modifier = Modifier.height(8.dp)) Row(verticalAlignment = Alignment.CenterVertically) { @@ -404,7 +472,7 @@ private fun DisclaimerDialog(updateMethod: FirmwareUpdateMethod, onDismissReques ) } if (updateMethod is FirmwareUpdateMethod.Ble) { - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(12.dp)) ChirpyCard() } } @@ -416,8 +484,11 @@ private fun DisclaimerDialog(updateMethod: FirmwareUpdateMethod, onDismissReques @Composable private fun ChirpyCard() { - Card(modifier = Modifier.fillMaxWidth().padding(4.dp)) { - Column(modifier = Modifier.fillMaxWidth().padding(4.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Card( + modifier = Modifier.fillMaxWidth().padding(4.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) { Row( modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.Bottom, @@ -428,6 +499,7 @@ private fun ChirpyCard() { model = ImageRequest.Builder(LocalContext.current) .data(org.meshtastic.core.ui.R.drawable.chirpy) + .crossfade(true) .build(), contentScale = ContentScale.Fit, contentDescription = stringResource(Res.string.chirpy), @@ -437,6 +509,7 @@ private fun ChirpyCard() { Text( text = stringResource(Res.string.firmware_update_disclaimer_chirpy_says), style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, ) } } @@ -446,8 +519,9 @@ private fun ChirpyCard() { private fun DeviceHardwareImage(deviceHardware: DeviceHardware, modifier: Modifier = Modifier) { val hwImg = deviceHardware.images?.getOrNull(1) ?: deviceHardware.images?.getOrNull(0) ?: "unknown.svg" val imageUrl = "https://flasher.meshtastic.org/img/devices/$hwImg" + AsyncImage( - model = ImageRequest.Builder(LocalContext.current).data(imageUrl).build(), + model = ImageRequest.Builder(LocalContext.current).data(imageUrl).crossfade(true).build(), contentScale = ContentScale.Fit, contentDescription = deviceHardware.displayName, modifier = modifier, @@ -456,32 +530,17 @@ private fun DeviceHardwareImage(deviceHardware: DeviceHardware, modifier: Modifi @Composable private fun ReleaseNotesCard(releaseNotes: String) { - var expanded by remember { mutableStateOf(false) } - ElevatedCard( - modifier = Modifier.fillMaxWidth(), - onClick = { expanded = !expanded }, + modifier = Modifier.fillMaxWidth().animateContentSize(), colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surface), ) { Column(modifier = Modifier.padding(16.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(text = "Release Notes", style = MaterialTheme.typography.titleMedium) - Icon( - imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = if (expanded) "Collapse" else "Expand", - ) - } - - AnimatedVisibility(visible = expanded) { - Column { - Spacer(modifier = Modifier.height(12.dp)) - Markdown(content = releaseNotes, modifier = Modifier.fillMaxWidth()) - } - } + Text( + text = stringResource(Res.string.firmware_update_release_notes), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(12.dp)) + Markdown(content = releaseNotes, modifier = Modifier.fillMaxWidth()) } } } @@ -491,11 +550,12 @@ private fun DeviceInfoCard( deviceHardware: DeviceHardware, release: FirmwareRelease?, currentFirmwareVersion: String? = null, + selectedReleaseType: FirmwareReleaseType = FirmwareReleaseType.STABLE, ) { val target = deviceHardware.hwModelSlug.ifEmpty { deviceHardware.platformioTarget } ElevatedCard( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().animateContentSize(), colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), ) { Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) { @@ -514,22 +574,28 @@ private fun DeviceInfoCard( } Spacer(Modifier.height(8.dp)) Text( - "Target: $target", + stringResource(Res.string.firmware_update_target, target), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(4.dp)) - val currentVersion = + val currentVersionString = stringResource( Res.string.firmware_update_currently_installed, currentFirmwareVersion ?: stringResource(Res.string.firmware_update_unknown_release), ) - Text(modifier = Modifier.fillMaxWidth(), text = currentVersion) + Text(modifier = Modifier.fillMaxWidth(), text = currentVersionString) Spacer(Modifier.height(4.dp)) - val releaseVersion = release?.title ?: stringResource(Res.string.firmware_update_unknown_release) + val (label, version) = + if (selectedReleaseType == FirmwareReleaseType.LOCAL) { + stringResource(Res.string.firmware_update_source_local) to "" + } else { + val releaseVersion = release?.title ?: stringResource(Res.string.firmware_update_unknown_release) + stringResource(Res.string.firmware_update_latest, "") to releaseVersion + } Text( modifier = Modifier.fillMaxWidth(), - text = stringResource(Res.string.firmware_update_latest, releaseVersion), + text = "$label$version", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary, ) @@ -540,7 +606,7 @@ private fun DeviceInfoCard( @Composable private fun BootloaderWarningCard(deviceHardware: DeviceHardware, onDismissForDevice: () -> Unit) { ElevatedCard( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().animateContentSize(), colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.errorContainer, @@ -607,65 +673,86 @@ private fun ReleaseTypeSelector( SegmentedButton( selected = selectedReleaseType == FirmwareReleaseType.STABLE, onClick = { onReleaseTypeSelect(FirmwareReleaseType.STABLE) }, - shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 3), ) { Text(stringResource(Res.string.firmware_update_stable)) } SegmentedButton( selected = selectedReleaseType == FirmwareReleaseType.ALPHA, onClick = { onReleaseTypeSelect(FirmwareReleaseType.ALPHA) }, - shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 3), ) { Text(stringResource(Res.string.firmware_update_alpha)) } + SegmentedButton( + selected = selectedReleaseType == FirmwareReleaseType.LOCAL, + onClick = { onReleaseTypeSelect(FirmwareReleaseType.LOCAL) }, + shape = SegmentedButtonDefaults.itemShape(index = 2, count = 3), + ) { + Text(stringResource(Res.string.firmware_update_local_file)) + } } } -@Suppress("MagicNumber") @Composable -private fun ColumnScope.DownloadingState(state: FirmwareUpdateState.Downloading) { - Icon( - Icons.Default.CloudDownload, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.height(24.dp)) - Text( - stringResource(Res.string.firmware_update_downloading, (state.progress * 100).toInt()), - style = MaterialTheme.typography.titleMedium, - ) - Spacer(Modifier.height(16.dp)) - LinearWavyProgressIndicator(progress = { state.progress }, modifier = Modifier.fillMaxWidth()) - Spacer(Modifier.height(16.dp)) - CyclingMessages() -} - -@Composable -private fun ColumnScope.ProcessingState(message: String) { - CircularWavyProgressIndicator(modifier = Modifier.size(64.dp)) - Spacer(Modifier.height(24.dp)) - Text(message, style = MaterialTheme.typography.titleMedium) - Spacer(Modifier.height(16.dp)) - CyclingMessages() -} - -@Composable -private fun ColumnScope.UpdatingState(state: FirmwareUpdateState.Updating) { - CircularWavyProgressIndicator(progress = { state.progress }, modifier = Modifier.size(64.dp)) - Spacer(Modifier.height(24.dp)) - Text(state.message, style = MaterialTheme.typography.titleMedium) - Spacer(Modifier.height(8.dp)) - LinearWavyProgressIndicator(progress = { state.progress }, modifier = Modifier.fillMaxWidth()) - Spacer(Modifier.height(16.dp)) - CyclingMessages() -} - -@Composable -private fun ColumnScope.AwaitingFileSaveState( - state: FirmwareUpdateState.AwaitingFileSave, - onSaveFile: (String) -> Unit, +private fun ProgressContent( + progressState: ProgressState, + onCancel: () -> Unit, + isDownloading: Boolean = false, + isUpdating: Boolean = false, ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + if (isDownloading) { + Icon( + Icons.Default.CloudDownload, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } else { + CircularWavyProgressIndicator( + progress = { if (isUpdating) progressState.progress else 1f }, + modifier = Modifier.size(64.dp), + ) + } + + Spacer(Modifier.height(24.dp)) + + Text(progressState.message, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center) + + val details = progressState.details + if (details != null) { + Spacer(Modifier.height(4.dp)) + Text( + text = details, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + + Spacer(Modifier.height(12.dp)) + + if (isDownloading || isUpdating) { + LinearWavyProgressIndicator( + progress = { progressState.progress }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + ) + } + + Spacer(Modifier.height(16.dp)) + CyclingMessages() + Spacer(Modifier.height(24.dp)) + OutlinedButton(onClick = onCancel) { Text(stringResource(Res.string.cancel)) } + } +} + +@Composable +private fun AwaitingFileSaveState(state: FirmwareUpdateState.AwaitingFileSave, onSaveFile: (String) -> Unit) { var showDialog by remember { mutableStateOf(true) } if (showDialog) { @@ -700,8 +787,6 @@ private fun ColumnScope.AwaitingFileSaveState( } } -private const val CYCLE_DELAY = 4000L - @Composable private fun CyclingMessages() { val messages = @@ -716,23 +801,48 @@ private fun CyclingMessages() { LaunchedEffect(Unit) { while (true) { - delay(CYCLE_DELAY) + delay(CYCLE_DELAY_MS) currentMessageIndex = (currentMessageIndex + 1) % messages.size } } - AnimatedContent(targetState = messages[currentMessageIndex], label = "CyclingMessage") { message -> - Text( - message, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) + Text( + messages[currentMessageIndex], + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().height(48.dp), + ) +} + +@Composable +private fun VerificationFailedState(onRetry: () -> Unit, onIgnore: () -> Unit) { + Icon( + Icons.Default.Warning, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(24.dp)) + Text( + stringResource(Res.string.firmware_update_verification_failed), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(32.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + OutlinedButton(onClick = onRetry) { + Icon(Icons.Default.Refresh, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(stringResource(Res.string.firmware_update_retry)) + } + Button(onClick = onIgnore) { Text(stringResource(Res.string.firmware_update_done)) } } } @Composable -private fun ColumnScope.ErrorState(error: String, onRetry: () -> Unit) { +private fun ErrorState(error: String, onRetry: () -> Unit) { Icon( Icons.Default.Dangerous, contentDescription = null, @@ -755,23 +865,28 @@ private fun ColumnScope.ErrorState(error: String, onRetry: () -> Unit) { } @Composable -private fun ColumnScope.SuccessState(onDone: () -> Unit) { - Icon( - Icons.Default.CheckCircle, - contentDescription = null, - modifier = Modifier.size(80.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.height(24.dp)) - Text( - stringResource(Res.string.firmware_update_success), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.headlineMedium, - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(32.dp)) - Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) { - Text(stringResource(Res.string.firmware_update_done)) +private fun SuccessState(onDone: () -> Unit) { + val haptic = LocalHapticFeedback.current + LaunchedEffect(Unit) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(100.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(24.dp)) + Text( + stringResource(Res.string.firmware_update_success), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.headlineLarge, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(32.dp)) + Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) { + Text(stringResource(Res.string.firmware_update_done)) + } } } diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt index c873134e1..3a3055391 100644 --- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-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 @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.firmware import android.net.Uri @@ -22,6 +21,15 @@ import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.model.DeviceHardware import java.io.File +/** + * Represents the progress of a long-running firmware update task. + * + * @property message A high-level status message (e.g., "Downloading..."). + * @property progress A value between 0.0 and 1.0 representing completion percentage. + * @property details Optional high-frequency detail text (e.g., "1.2 MiB/s, 45%"). + */ +data class ProgressState(val message: String = "", val progress: Float = 0f, val details: String? = null) + sealed interface FirmwareUpdateState { data object Idle : FirmwareUpdateState @@ -36,11 +44,15 @@ sealed interface FirmwareUpdateState { val currentFirmwareVersion: String? = null, ) : FirmwareUpdateState - data class Downloading(val progress: Float) : FirmwareUpdateState + data class Downloading(val progressState: ProgressState) : FirmwareUpdateState - data class Processing(val message: String) : FirmwareUpdateState + data class Processing(val progressState: ProgressState) : FirmwareUpdateState - data class Updating(val progress: Float, val message: String) : FirmwareUpdateState + data class Updating(val progressState: ProgressState) : FirmwareUpdateState + + data object Verifying : FirmwareUpdateState + + data object VerificationFailed : FirmwareUpdateState data class Error(val error: String) : FirmwareUpdateState diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt index a9f938b90..c07a9adf8 100644 --- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-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 @@ -14,37 +14,27 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.firmware -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.hardware.usb.UsbManager import android.net.Uri -import android.os.Build import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull -import no.nordicsemi.android.dfu.DfuProgressListenerAdapter -import no.nordicsemi.android.dfu.DfuServiceListenerHelper import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString import org.meshtastic.core.data.repository.DeviceHardwareRepository @@ -57,21 +47,30 @@ import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.prefs.radio.RadioPrefs import org.meshtastic.core.prefs.radio.isBle import org.meshtastic.core.prefs.radio.isSerial +import org.meshtastic.core.prefs.radio.isTcp +import org.meshtastic.core.service.ConnectionState import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.firmware_update_battery_low import org.meshtastic.core.strings.firmware_update_copying +import org.meshtastic.core.strings.firmware_update_dfu_aborted +import org.meshtastic.core.strings.firmware_update_dfu_error +import org.meshtastic.core.strings.firmware_update_disconnecting +import org.meshtastic.core.strings.firmware_update_enabling_dfu import org.meshtastic.core.strings.firmware_update_extracting import org.meshtastic.core.strings.firmware_update_failed import org.meshtastic.core.strings.firmware_update_flashing +import org.meshtastic.core.strings.firmware_update_local_failed import org.meshtastic.core.strings.firmware_update_method_ble import org.meshtastic.core.strings.firmware_update_method_usb +import org.meshtastic.core.strings.firmware_update_method_wifi import org.meshtastic.core.strings.firmware_update_no_device -import org.meshtastic.core.strings.firmware_update_not_found_in_release -import org.meshtastic.core.strings.firmware_update_rebooting +import org.meshtastic.core.strings.firmware_update_node_info_missing import org.meshtastic.core.strings.firmware_update_starting_dfu -import org.meshtastic.core.strings.firmware_update_starting_service +import org.meshtastic.core.strings.firmware_update_unknown_error import org.meshtastic.core.strings.firmware_update_unknown_hardware import org.meshtastic.core.strings.firmware_update_updating +import org.meshtastic.core.strings.firmware_update_validating import org.meshtastic.core.strings.unknown import java.io.File import javax.inject.Inject @@ -79,11 +78,16 @@ import javax.inject.Inject private const val DFU_RECONNECT_PREFIX = "x" private const val PERCENT_MAX_VALUE = 100f private const val DEVICE_DETACH_TIMEOUT = 30_000L +private const val VERIFY_TIMEOUT = 60_000L +private const val VERIFY_DELAY = 2000L +private const val MIN_BATTERY_LEVEL = 10 +private const val KIB_DIVISOR = 1024f +private const val MILLIS_PER_SECOND = 1000L private val BLUETOOTH_ADDRESS_REGEX = Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}") @HiltViewModel -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions") class FirmwareUpdateViewModel @Inject constructor( @@ -92,10 +96,9 @@ constructor( private val nodeRepository: NodeRepository, private val serviceRepository: ServiceRepository, private val radioPrefs: RadioPrefs, - @ApplicationContext private val context: Context, private val bootloaderWarningDataSource: BootloaderWarningDataSource, - private val otaUpdateHandler: OtaUpdateHandler, - private val usbUpdateHandler: UsbUpdateHandler, + private val firmwareUpdateManager: FirmwareUpdateManager, + private val usbManager: UsbManager, private val fileHandler: FirmwareFileHandler, ) : ViewModel() { @@ -105,8 +108,18 @@ constructor( private val _selectedReleaseType = MutableStateFlow(FirmwareReleaseType.STABLE) val selectedReleaseType: StateFlow = _selectedReleaseType.asStateFlow() + private val _selectedRelease = MutableStateFlow(null) + val selectedRelease: StateFlow = _selectedRelease.asStateFlow() + + private val _deviceHardware = MutableStateFlow(null) + val deviceHardware = _deviceHardware.asStateFlow() + + private val _currentFirmwareVersion = MutableStateFlow(null) + val currentFirmwareVersion = _currentFirmwareVersion.asStateFlow() + private var updateJob: Job? = null private var tempFirmwareFile: File? = null + private var originalDeviceAddress: String? = null init { // Cleanup potential leftovers @@ -127,6 +140,12 @@ constructor( checkForUpdates() } + fun cancelUpdate() { + updateJob?.cancel() + _state.value = FirmwareUpdateState.Idle + checkForUpdates() + } + fun checkForUpdates() { updateJob?.cancel() updateJob = @@ -140,15 +159,26 @@ constructor( return@launch } getDeviceHardware(ourNode)?.let { deviceHardware -> - firmwareReleaseRepository.getReleaseFlow( - _selectedReleaseType.value, - ).collectLatest { release -> + _deviceHardware.value = deviceHardware + _currentFirmwareVersion.value = ourNode.metadata?.firmwareVersion + + val releaseFlow = + if (_selectedReleaseType.value == FirmwareReleaseType.LOCAL) { + kotlinx.coroutines.flow.flowOf(null) + } else { + firmwareReleaseRepository.getReleaseFlow(_selectedReleaseType.value) + } + + releaseFlow.collectLatest { release -> + _selectedRelease.value = release val dismissed = bootloaderWarningDataSource.isDismissed(address) val firmwareUpdateMethod = if (radioPrefs.isSerial()) { FirmwareUpdateMethod.Usb } else if (radioPrefs.isBle()) { FirmwareUpdateMethod.Ble + } else if (radioPrefs.isTcp()) { + FirmwareUpdateMethod.Wifi } else { FirmwareUpdateMethod.Unknown } @@ -170,7 +200,8 @@ constructor( .onFailure { e -> if (e is CancellationException) throw e Logger.e(e) { "Error checking for updates" } - _state.value = FirmwareUpdateState.Error(e.message ?: "Unknown error") + val unknownError = getString(Res.string.firmware_update_unknown_error) + _state.value = FirmwareUpdateState.Error(e.message ?: unknownError) } } } @@ -178,34 +209,37 @@ constructor( fun startUpdate() { val currentState = _state.value as? FirmwareUpdateState.Ready ?: return val release = currentState.release ?: return + originalDeviceAddress = currentState.address - updateJob?.cancel() - updateJob = - viewModelScope.launch { - if (radioPrefs.isSerial()) { - tempFirmwareFile = - usbUpdateHandler.startUpdate( - release = release, - hardware = currentState.deviceHardware, - updateState = { _state.value = it }, - rebootingMsg = getString(Res.string.firmware_update_rebooting), - ) - } else if (radioPrefs.isBle()) { - tempFirmwareFile = - otaUpdateHandler.startUpdate( - release = release, - hardware = currentState.deviceHardware, - address = currentState.address, - updateState = { _state.value = it }, - notFoundMsg = - getString( - Res.string.firmware_update_not_found_in_release, - currentState.deviceHardware.displayName, - ), - startingMsg = getString(Res.string.firmware_update_starting_service), - ) - } + viewModelScope.launch { + if (checkBatteryLevel()) { + updateJob?.cancel() + updateJob = + viewModelScope.launch { + try { + tempFirmwareFile = + firmwareUpdateManager.startUpdate( + release = release, + hardware = currentState.deviceHardware, + address = currentState.address, + updateState = { _state.value = it }, + ) + + if (_state.value is FirmwareUpdateState.Success) { + verifyUpdateResult(originalDeviceAddress) + } + } catch (e: CancellationException) { + Logger.i { "Firmware update cancelled" } + _state.value = FirmwareUpdateState.Idle + checkForUpdates() + throw e + } catch (e: Exception) { + val failedMsg = getString(Res.string.firmware_update_failed) + _state.value = FirmwareUpdateState.Error(e.message ?: failedMsg) + } + } } + } } fun saveDfuFile(uri: Uri) { @@ -215,23 +249,26 @@ constructor( viewModelScope.launch { try { - _state.value = FirmwareUpdateState.Processing(getString(Res.string.firmware_update_copying)) + val copyingMsg = getString(Res.string.firmware_update_copying) + _state.value = FirmwareUpdateState.Processing(ProgressState(copyingMsg)) if (firmwareFile != null) { fileHandler.copyFileToUri(firmwareFile, uri) } else if (sourceUri != null) { fileHandler.copyUriToUri(sourceUri, uri) } - _state.value = FirmwareUpdateState.Processing(getString(Res.string.firmware_update_flashing)) - withTimeoutOrNull(DEVICE_DETACH_TIMEOUT) { waitForDeviceDetach(context).first() } + val flashingMsg = getString(Res.string.firmware_update_flashing) + _state.value = FirmwareUpdateState.Processing(ProgressState(flashingMsg)) + withTimeoutOrNull(DEVICE_DETACH_TIMEOUT) { usbManager.deviceDetachFlow().first() } ?: Logger.w { "Timed out waiting for device to detach, assuming success" } - _state.value = FirmwareUpdateState.Success + verifyUpdateResult(originalDeviceAddress) } catch (e: CancellationException) { throw e } catch (e: Exception) { Logger.e(e) { "Error saving DFU file" } - _state.value = FirmwareUpdateState.Error(e.message ?: getString(Res.string.firmware_update_failed)) + val failedMsg = getString(Res.string.firmware_update_failed) + _state.value = FirmwareUpdateState.Error(e.message ?: failedMsg) } finally { cleanupTemporaryFiles(fileHandler, tempFirmwareFile) } @@ -241,46 +278,45 @@ constructor( fun startUpdateFromFile(uri: Uri) { val currentState = _state.value as? FirmwareUpdateState.Ready ?: return if (currentState.updateMethod is FirmwareUpdateMethod.Ble && !isValidBluetoothAddress(currentState.address)) { + viewModelScope.launch { + val noDeviceMsg = getString(Res.string.firmware_update_no_device) + _state.value = FirmwareUpdateState.Error(noDeviceMsg) + } return } + originalDeviceAddress = currentState.address updateJob?.cancel() updateJob = viewModelScope.launch { try { - _state.value = FirmwareUpdateState.Processing(getString(Res.string.firmware_update_extracting)) + val extractingMsg = getString(Res.string.firmware_update_extracting) + _state.value = FirmwareUpdateState.Processing(ProgressState(extractingMsg)) val extension = if (currentState.updateMethod is FirmwareUpdateMethod.Ble) ".zip" else ".uf2" val extractedFile = fileHandler.extractFirmware(uri, currentState.deviceHardware, extension) tempFirmwareFile = extractedFile val firmwareUri = if (extractedFile != null) Uri.fromFile(extractedFile) else uri - if (currentState.updateMethod is FirmwareUpdateMethod.Ble) { - otaUpdateHandler.startUpdate( + tempFirmwareFile = + firmwareUpdateManager.startUpdate( release = FirmwareRelease(id = "local", title = "Local File", zipUrl = "", releaseNotes = ""), hardware = currentState.deviceHardware, address = currentState.address, updateState = { _state.value = it }, - notFoundMsg = "File not found", - startingMsg = getString(Res.string.firmware_update_starting_service), - firmwareUri = firmwareUri, - ) - } else if (currentState.updateMethod is FirmwareUpdateMethod.Usb) { - usbUpdateHandler.startUpdate( - release = - FirmwareRelease(id = "local", title = "Local File", zipUrl = "", releaseNotes = ""), - hardware = currentState.deviceHardware, - updateState = { _state.value = it }, - rebootingMsg = getString(Res.string.firmware_update_rebooting), firmwareUri = firmwareUri, ) + + if (_state.value is FirmwareUpdateState.Success) { + verifyUpdateResult(originalDeviceAddress) } } catch (e: CancellationException) { throw e } catch (e: Exception) { Logger.e(e) { "Error starting update from file" } - _state.value = FirmwareUpdateState.Error(e.message ?: "Local update failed") + val failedMsg = getString(Res.string.firmware_update_local_failed) + _state.value = FirmwareUpdateState.Error(e.message ?: failedMsg) } } } @@ -294,37 +330,131 @@ constructor( } private suspend fun observeDfuProgress() { - dfuProgressFlow(context).flowOn(Dispatchers.Main).collect { dfuState -> + firmwareUpdateManager.dfuProgressFlow().flowOn(Dispatchers.Main).collect { dfuState -> when (dfuState) { - is DfuInternalState.Progress -> { - val msg = getString(Res.string.firmware_update_updating, "${dfuState.percent}") - _state.value = FirmwareUpdateState.Updating(dfuState.percent / PERCENT_MAX_VALUE, msg) - } + is DfuInternalState.Progress -> handleDfuProgress(dfuState) is DfuInternalState.Error -> { - _state.value = FirmwareUpdateState.Error("DFU Error: ${dfuState.message}") + val errorMsg = getString(Res.string.firmware_update_dfu_error, dfuState.message ?: "") + _state.value = FirmwareUpdateState.Error(errorMsg) tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile) } is DfuInternalState.Completed -> { - _state.value = FirmwareUpdateState.Success - serviceRepository.meshService?.setDeviceAddress("$DFU_RECONNECT_PREFIX${dfuState.address}") tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile) + verifyUpdateResult(originalDeviceAddress) } is DfuInternalState.Aborted -> { - _state.value = FirmwareUpdateState.Error("DFU Aborted") + val abortedMsg = getString(Res.string.firmware_update_dfu_aborted) + _state.value = FirmwareUpdateState.Error(abortedMsg) tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile) } is DfuInternalState.Starting -> { val msg = getString(Res.string.firmware_update_starting_dfu) - _state.value = FirmwareUpdateState.Processing(msg) + _state.value = FirmwareUpdateState.Processing(ProgressState(msg)) } + + is DfuInternalState.EnablingDfuMode -> { + val msg = getString(Res.string.firmware_update_enabling_dfu) + _state.value = FirmwareUpdateState.Processing(ProgressState(msg)) + } + + is DfuInternalState.Validating -> { + val msg = getString(Res.string.firmware_update_validating) + _state.value = FirmwareUpdateState.Processing(ProgressState(msg)) + } + + is DfuInternalState.Disconnecting -> { + val msg = getString(Res.string.firmware_update_disconnecting) + _state.value = FirmwareUpdateState.Processing(ProgressState(msg)) + } + + else -> {} // ignore connected/disconnected for UI noise } } } + private fun handleDfuProgress(dfuState: DfuInternalState.Progress) { + val progress = dfuState.percent / PERCENT_MAX_VALUE + val percentText = "${dfuState.percent}%" + + // Nordic DFU speed is in Bytes/ms. Convert to KiB/s. + val speedBytesPerSec = dfuState.speed * MILLIS_PER_SECOND + val speedKib = speedBytesPerSec / KIB_DIVISOR + + // Calculate ETA + val totalBytes = tempFirmwareFile?.length() ?: 0L + val etaText = + if (totalBytes > 0 && speedBytesPerSec > 0 && dfuState.percent > 0) { + val remainingBytes = totalBytes * (1f - progress) + val etaSeconds = remainingBytes / speedBytesPerSec + ", ETA: ${etaSeconds.toInt()}s" + } else { + "" + } + + val partInfo = + if (dfuState.partsTotal > 1) { + " (Part ${dfuState.currentPart}/${dfuState.partsTotal})" + } else { + "" + } + + val metrics = + if (dfuState.speed > 0) { + String.format(java.util.Locale.US, "%.1f KiB/s%s%s", speedKib, etaText, partInfo) + } else { + partInfo + } + viewModelScope.launch { + val statusMsg = + getString(Res.string.firmware_update_updating, "").replace(Regex(":?\\s*%1\\\$s%?"), "").trim() + val details = "$percentText ($metrics)" + _state.value = FirmwareUpdateState.Updating(ProgressState(statusMsg, progress, details)) + } + } + + private suspend fun verifyUpdateResult(address: String?) { + _state.value = FirmwareUpdateState.Verifying + + // Trigger a fresh connection attempt by MeshService + address?.let { currentAddr -> + Logger.i { "Post-update: Requesting MeshService to reconnect to $currentAddr" } + serviceRepository.meshService?.setDeviceAddress("$DFU_RECONNECT_PREFIX$currentAddr") + } + + // Wait for device to reconnect and settle + val result = + withTimeoutOrNull(VERIFY_TIMEOUT) { + // Wait for both Connected state and node info to be present + serviceRepository.connectionState.first { it is ConnectionState.Connected } + nodeRepository.ourNodeInfo.filterNotNull().first() + delay(VERIFY_DELAY) // Extra buffer for initial config sync + true + } + + if (result == null) { + Logger.w { "Post-update verification timed out for $address" } + _state.value = FirmwareUpdateState.VerificationFailed + } else { + _state.value = FirmwareUpdateState.Success + } + } + + private suspend fun checkBatteryLevel(): Boolean { + val node = nodeRepository.ourNodeInfo.value ?: return true + val level = node.batteryLevel + val isBatteryLow = level in 1..MIN_BATTERY_LEVEL + + if (isBatteryLow) { + val batteryLowMsg = getString(Res.string.firmware_update_battery_low, level) + _state.value = FirmwareUpdateState.Error(batteryLowMsg) + } + return !isBatteryLow + } + private suspend fun getDeviceHardware(ourNode: org.meshtastic.core.database.model.Node): DeviceHardware? { val hwModel = ourNode.user.hwModel?.number return if (hwModel != null) { @@ -334,7 +464,8 @@ constructor( null } } else { - _state.value = FirmwareUpdateState.Error("Node user information is missing.") + val nodeInfoMissing = getString(Res.string.firmware_update_node_info_missing) + _state.value = FirmwareUpdateState.Error(nodeInfoMissing) null } } @@ -349,80 +480,13 @@ private fun cleanupTemporaryFiles(fileHandler: FirmwareFileHandler, tempFirmware return null } -private fun waitForDeviceDetach(context: Context): Flow = callbackFlow { - val receiver = - object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED) { - trySend(Unit).isSuccess - close() - } - } - } - val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) - } else { - @Suppress("UnspecifiedRegisterReceiverFlag") - context.registerReceiver(receiver, filter) - } - awaitClose { context.unregisterReceiver(receiver) } -} - -private sealed interface DfuInternalState { - data class Starting(val address: String) : DfuInternalState - - data class Progress(val address: String, val percent: Int) : DfuInternalState - - data class Completed(val address: String) : DfuInternalState - - data class Aborted(val address: String) : DfuInternalState - - data class Error(val address: String, val message: String?) : DfuInternalState -} - private fun isValidBluetoothAddress(address: String?): Boolean = address != null && BLUETOOTH_ADDRESS_REGEX.matches(address) private fun FirmwareReleaseRepository.getReleaseFlow(type: FirmwareReleaseType): Flow = when (type) { FirmwareReleaseType.STABLE -> stableRelease FirmwareReleaseType.ALPHA -> alphaRelease -} - -private fun dfuProgressFlow(context: Context): Flow = callbackFlow { - val listener = - object : DfuProgressListenerAdapter() { - override fun onDfuProcessStarting(deviceAddress: String) { - trySend(DfuInternalState.Starting(deviceAddress)) - } - - override fun onProgressChanged( - deviceAddress: String, - percent: Int, - speed: Float, - avgSpeed: Float, - currentPart: Int, - partsTotal: Int, - ) { - trySend(DfuInternalState.Progress(deviceAddress, percent)) - } - - override fun onDfuCompleted(deviceAddress: String) { - trySend(DfuInternalState.Completed(deviceAddress)) - } - - override fun onDfuAborted(deviceAddress: String) { - trySend(DfuInternalState.Aborted(deviceAddress)) - } - - override fun onError(deviceAddress: String, error: Int, errorType: Int, message: String?) { - trySend(DfuInternalState.Error(deviceAddress, message)) - } - } - - DfuServiceListenerHelper.registerProgressListener(context, listener) - awaitClose { DfuServiceListenerHelper.unregisterProgressListener(context, listener) } + FirmwareReleaseType.LOCAL -> kotlinx.coroutines.flow.flowOf(null) } sealed class FirmwareUpdateMethod(val description: StringResource) { @@ -430,5 +494,7 @@ sealed class FirmwareUpdateMethod(val description: StringResource) { object Ble : FirmwareUpdateMethod(Res.string.firmware_update_method_ble) + object Wifi : FirmwareUpdateMethod(Res.string.firmware_update_method_wifi) + object Unknown : FirmwareUpdateMethod(Res.string.unknown) } diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/NordicDfuHandler.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/NordicDfuHandler.kt new file mode 100644 index 000000000..0d753ddad --- /dev/null +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/NordicDfuHandler.kt @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.firmware + +import android.content.Context +import android.net.Uri +import co.touchlab.kermit.Logger +import co.touchlab.kermit.Severity +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import no.nordicsemi.android.dfu.DfuBaseService +import no.nordicsemi.android.dfu.DfuLogListener +import no.nordicsemi.android.dfu.DfuProgressListenerAdapter +import no.nordicsemi.android.dfu.DfuServiceInitiator +import no.nordicsemi.android.dfu.DfuServiceListenerHelper +import org.jetbrains.compose.resources.getString +import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.firmware_update_downloading_percent +import org.meshtastic.core.strings.firmware_update_nordic_failed +import org.meshtastic.core.strings.firmware_update_not_found_in_release +import org.meshtastic.core.strings.firmware_update_starting_service +import java.io.File +import javax.inject.Inject + +private const val SCAN_TIMEOUT = 5000L +private const val PACKETS_BEFORE_PRN = 8 +private const val PERCENT_MAX = 100 +private const val PREPARE_DATA_DELAY = 400L + +/** Handles Over-the-Air (OTA) firmware updates for nRF52-based devices using the Nordic DFU library. */ +class NordicDfuHandler +@Inject +constructor( + private val firmwareRetriever: FirmwareRetriever, + @ApplicationContext private val context: Context, + private val serviceRepository: ServiceRepository, +) : FirmwareUpdateHandler { + + override suspend fun startUpdate( + release: FirmwareRelease, + hardware: DeviceHardware, + target: String, // Bluetooth address + updateState: (FirmwareUpdateState) -> Unit, + firmwareUri: Uri?, + ): File? = + try { + val downloadingMsg = + getString(Res.string.firmware_update_downloading_percent, 0) + .replace(Regex(":?\\s*%1\\\$d%?"), "") + .trim() + + updateState(FirmwareUpdateState.Downloading(ProgressState(message = downloadingMsg, progress = 0f))) + + if (firmwareUri != null) { + initiateDfu(target, hardware, firmwareUri, updateState) + null + } else { + val firmwareFile = + firmwareRetriever.retrieveOtaFirmware(release, hardware) { progress -> + val percent = (progress * PERCENT_MAX).toInt() + updateState( + FirmwareUpdateState.Downloading( + ProgressState(message = downloadingMsg, progress = progress, details = "$percent%"), + ), + ) + } + + if (firmwareFile == null) { + val errorMsg = getString(Res.string.firmware_update_not_found_in_release, hardware.displayName) + updateState(FirmwareUpdateState.Error(errorMsg)) + null + } else { + initiateDfu(target, hardware, Uri.fromFile(firmwareFile), updateState) + firmwareFile + } + } + } catch (e: CancellationException) { + throw e + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.e(e) { "Nordic DFU Update failed" } + val errorMsg = getString(Res.string.firmware_update_nordic_failed) + updateState(FirmwareUpdateState.Error(e.message ?: errorMsg)) + null + } + + private suspend fun initiateDfu( + address: String, + deviceHardware: DeviceHardware, + firmwareUri: Uri, + updateState: (FirmwareUpdateState) -> Unit, + ) { + val startingMsg = getString(Res.string.firmware_update_starting_service) + updateState(FirmwareUpdateState.Processing(ProgressState(startingMsg))) + + // n = Nordic (Legacy prefix handling in mesh service) + serviceRepository.meshService?.setDeviceAddress("n") + + DfuServiceInitiator(address) + .setDeviceName(deviceHardware.displayName) + .setPrepareDataObjectDelay(PREPARE_DATA_DELAY) + .setForceScanningForNewAddressInLegacyDfu(true) + .setRestoreBond(true) + .setForeground(true) + .setKeepBond(true) + .setForceDfu(false) + .setPacketsReceiptNotificationsValue(PACKETS_BEFORE_PRN) + .setPacketsReceiptNotificationsEnabled(true) + .setScanTimeout(SCAN_TIMEOUT) + .setUnsafeExperimentalButtonlessServiceInSecureDfuEnabled(true) + .setZip(firmwareUri) + .start(context, FirmwareDfuService::class.java) + } + + /** Observe DFU progress and events. */ + fun progressFlow(): Flow = callbackFlow { + val listener = + object : DfuProgressListenerAdapter() { + override fun onDeviceConnecting(deviceAddress: String) { + trySend(DfuInternalState.Connecting(deviceAddress)) + } + + override fun onDeviceConnected(deviceAddress: String) { + trySend(DfuInternalState.Connected(deviceAddress)) + } + + override fun onDfuProcessStarting(deviceAddress: String) { + trySend(DfuInternalState.Starting(deviceAddress)) + } + + override fun onEnablingDfuMode(deviceAddress: String) { + trySend(DfuInternalState.EnablingDfuMode(deviceAddress)) + } + + override fun onProgressChanged( + deviceAddress: String, + percent: Int, + speed: Float, + avgSpeed: Float, + currentPart: Int, + partsTotal: Int, + ) { + trySend(DfuInternalState.Progress(deviceAddress, percent, speed, avgSpeed, currentPart, partsTotal)) + } + + override fun onFirmwareValidating(deviceAddress: String) { + trySend(DfuInternalState.Validating(deviceAddress)) + } + + override fun onDeviceDisconnecting(deviceAddress: String) { + trySend(DfuInternalState.Disconnecting(deviceAddress)) + } + + override fun onDeviceDisconnected(deviceAddress: String) { + trySend(DfuInternalState.Disconnected(deviceAddress)) + } + + override fun onDfuCompleted(deviceAddress: String) { + trySend(DfuInternalState.Completed(deviceAddress)) + } + + override fun onDfuAborted(deviceAddress: String) { + trySend(DfuInternalState.Aborted(deviceAddress)) + } + + override fun onError(deviceAddress: String, error: Int, errorType: Int, message: String?) { + trySend(DfuInternalState.Error(deviceAddress, message)) + } + } + + val logListener = + object : DfuLogListener { + override fun onLogEvent(deviceAddress: String, level: Int, message: String) { + val severity = + when (level) { + DfuBaseService.LOG_LEVEL_DEBUG -> Severity.Debug + DfuBaseService.LOG_LEVEL_INFO -> Severity.Info + DfuBaseService.LOG_LEVEL_APPLICATION -> Severity.Info + DfuBaseService.LOG_LEVEL_WARNING -> Severity.Warn + DfuBaseService.LOG_LEVEL_ERROR -> Severity.Error + else -> Severity.Verbose + } + Logger.log(severity, tag = "NordicDFU", null, "[$deviceAddress] $message") + } + } + + DfuServiceListenerHelper.registerProgressListener(context, listener) + DfuServiceListenerHelper.registerLogListener(context, logListener) + + awaitClose { + runCatching { + DfuServiceListenerHelper.unregisterProgressListener(context, listener) + DfuServiceListenerHelper.unregisterLogListener(context, logListener) + } + .onFailure { Logger.w(it) { "Failed to unregister DFU listeners" } } + } + } +} + +sealed interface DfuInternalState { + val address: String + + data class Connecting(override val address: String) : DfuInternalState + + data class Connected(override val address: String) : DfuInternalState + + data class Starting(override val address: String) : DfuInternalState + + data class EnablingDfuMode(override val address: String) : DfuInternalState + + data class Progress( + override val address: String, + val percent: Int, + val speed: Float, + val avgSpeed: Float, + val currentPart: Int, + val partsTotal: Int, + ) : DfuInternalState + + data class Validating(override val address: String) : DfuInternalState + + data class Disconnecting(override val address: String) : DfuInternalState + + data class Disconnected(override val address: String) : DfuInternalState + + data class Completed(override val address: String) : DfuInternalState + + data class Aborted(override val address: String) : DfuInternalState + + data class Error(override val address: String, val message: String?) : DfuInternalState +} diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/UpdateHandler.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/UpdateHandler.kt deleted file mode 100644 index 8f23d2270..000000000 --- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/UpdateHandler.kt +++ /dev/null @@ -1,221 +0,0 @@ -/* - * Copyright (c) 2025 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.meshtastic.feature.firmware - -import android.content.Context -import android.net.Uri -import co.touchlab.kermit.Logger -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.delay -import no.nordicsemi.android.dfu.DfuServiceInitiator -import org.meshtastic.core.database.entity.FirmwareRelease -import org.meshtastic.core.model.DeviceHardware -import org.meshtastic.core.service.ServiceRepository -import java.io.File -import javax.inject.Inject - -private const val SCAN_TIMEOUT = 2000L -private const val PACKETS_BEFORE_PRN = 8 -private const val REBOOT_DELAY = 5000L - -private const val DATA_OBJECT_DELAY = 400L - -/** Retrieves firmware files, either by direct download or by extracting from a release asset. */ -class FirmwareRetriever @Inject constructor(private val fileHandler: FirmwareFileHandler) { - suspend fun retrieveOtaFirmware( - release: FirmwareRelease, - hardware: DeviceHardware, - onProgress: (Float) -> Unit, - ): File? = retrieve( - release = release, - hardware = hardware, - onProgress = onProgress, - fileSuffix = "-ota.zip", - internalFileExtension = ".zip", - ) - - suspend fun retrieveUsbFirmware( - release: FirmwareRelease, - hardware: DeviceHardware, - onProgress: (Float) -> Unit, - ): File? = retrieve( - release = release, - hardware = hardware, - onProgress = onProgress, - fileSuffix = ".uf2", - internalFileExtension = ".uf2", - ) - - private suspend fun retrieve( - release: FirmwareRelease, - hardware: DeviceHardware, - onProgress: (Float) -> Unit, - fileSuffix: String, - internalFileExtension: String, - ): File? { - val version = release.id.removePrefix("v") - val target = hardware.platformioTarget.ifEmpty { hardware.hwModelSlug } - val filename = "firmware-$target-$version$fileSuffix" - val directUrl = - "https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-$version/$filename" - - if (fileHandler.checkUrlExists(directUrl)) { - try { - fileHandler.downloadFile(directUrl, filename, onProgress)?.let { - return it - } - } catch (e: Exception) { - Logger.w(e) { "Direct download for $filename failed, falling back to release zip" } - } - } - - // Fallback to downloading the full release zip and extracting - val zipUrl = getDeviceFirmwareUrl(release.zipUrl, hardware.architecture) - val downloadedZip = fileHandler.downloadFile(zipUrl, "firmware_release.zip", onProgress) - return downloadedZip?.let { fileHandler.extractFirmware(it, hardware, internalFileExtension) } - } - - private fun getDeviceFirmwareUrl(url: String, targetArch: String): String { - val knownArchs = listOf("esp32-s3", "esp32-c3", "esp32-c6", "nrf52840", "rp2040", "stm32", "esp32") - for (arch in knownArchs) { - if (url.contains(arch, ignoreCase = true)) { - return url.replace(arch, targetArch.lowercase(), ignoreCase = true) - } - } - return url - } -} - -/** Handles the logic for Over-the-Air (OTA) firmware updates via Bluetooth. */ -class OtaUpdateHandler -@Inject -constructor( - private val firmwareRetriever: FirmwareRetriever, - @ApplicationContext private val context: Context, - private val serviceRepository: ServiceRepository, -) { - suspend fun startUpdate( - release: FirmwareRelease, - hardware: DeviceHardware, - address: String, - updateState: (FirmwareUpdateState) -> Unit, - notFoundMsg: String, - startingMsg: String, - firmwareUri: Uri? = null, - ): File? = try { - updateState(FirmwareUpdateState.Downloading(0f)) - - if (firmwareUri != null) { - initiateDfu(address, hardware, firmwareUri, updateState, startingMsg) - null - } else { - val firmwareFile = - firmwareRetriever.retrieveOtaFirmware(release, hardware) { progress -> - updateState(FirmwareUpdateState.Downloading(progress)) - } - - if (firmwareFile == null) { - updateState(FirmwareUpdateState.Error(notFoundMsg)) - null - } else { - initiateDfu(address, hardware, Uri.fromFile(firmwareFile), updateState, startingMsg) - firmwareFile - } - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Logger.e(e) { "OTA Update failed" } - updateState(FirmwareUpdateState.Error(e.message ?: "OTA Update failed")) - null - } - - private fun initiateDfu( - address: String, - deviceHardware: DeviceHardware, - firmwareUri: Uri, - updateState: (FirmwareUpdateState) -> Unit, - startingMsg: String, - ) { - updateState(FirmwareUpdateState.Processing(startingMsg)) - serviceRepository.meshService?.setDeviceAddress("n") - - DfuServiceInitiator(address) - .disableResume() - .setDeviceName(deviceHardware.displayName) - .setForceScanningForNewAddressInLegacyDfu(true) - .setForeground(true) - .setKeepBond(true) - .setForceDfu(false) - .setPrepareDataObjectDelay(DATA_OBJECT_DELAY) - .setPacketsReceiptNotificationsValue(PACKETS_BEFORE_PRN) - .setScanTimeout(SCAN_TIMEOUT) - .setUnsafeExperimentalButtonlessServiceInSecureDfuEnabled(true) - .setZip(firmwareUri) - .start(context, FirmwareDfuService::class.java) - } -} - -/** Handles the logic for firmware updates via USB. */ -class UsbUpdateHandler -@Inject -constructor( - private val firmwareRetriever: FirmwareRetriever, - private val serviceRepository: ServiceRepository, -) { - suspend fun startUpdate( - release: FirmwareRelease, - hardware: DeviceHardware, - updateState: (FirmwareUpdateState) -> Unit, - rebootingMsg: String, - firmwareUri: Uri? = null, - ): File? = try { - updateState(FirmwareUpdateState.Downloading(0f)) - - if (firmwareUri != null) { - updateState(FirmwareUpdateState.Processing(rebootingMsg)) - serviceRepository.meshService?.rebootToDfu() - delay(REBOOT_DELAY) - updateState(FirmwareUpdateState.AwaitingFileSave(null, "firmware.uf2", firmwareUri)) - null - } else { - val firmwareFile = - firmwareRetriever.retrieveUsbFirmware(release, hardware) { progress -> - updateState(FirmwareUpdateState.Downloading(progress)) - } - - if (firmwareFile == null) { - updateState(FirmwareUpdateState.Error("Could not retrieve firmware file.")) - null - } else { - updateState(FirmwareUpdateState.Processing(rebootingMsg)) - serviceRepository.meshService?.rebootToDfu() - delay(REBOOT_DELAY) - updateState(FirmwareUpdateState.AwaitingFileSave(firmwareFile, firmwareFile.name)) - firmwareFile - } - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Logger.e(e) { "USB Update failed" } - updateState(FirmwareUpdateState.Error(e.message ?: "USB Update failed")) - null - } -} diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/UsbManager.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/UsbManager.kt new file mode 100644 index 000000000..9e8954280 --- /dev/null +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/UsbManager.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.firmware + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbManager +import android.os.Build +import co.touchlab.kermit.Logger +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** Manages USB-related interactions for firmware updates. */ +@Singleton +class UsbManager @Inject constructor(@ApplicationContext private val context: Context) { + /** Observe when a USB device is detached. */ + fun deviceDetachFlow(): Flow = callbackFlow { + val receiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED) { + trySend(Unit).isSuccess + close() + } + } + } + val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + @Suppress("UnspecifiedRegisterReceiverFlag") + context.registerReceiver(receiver, filter) + } + + awaitClose { + runCatching { context.unregisterReceiver(receiver) } + .onFailure { Logger.w(it) { "Failed to unregister USB receiver" } } + } + } +} diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/UsbUpdateHandler.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/UsbUpdateHandler.kt new file mode 100644 index 000000000..3c018a820 --- /dev/null +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/UsbUpdateHandler.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.firmware + +import android.net.Uri +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.getString +import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.firmware_update_downloading_percent +import org.meshtastic.core.strings.firmware_update_rebooting +import org.meshtastic.core.strings.firmware_update_retrieval_failed +import org.meshtastic.core.strings.firmware_update_usb_failed +import java.io.File +import javax.inject.Inject + +private const val REBOOT_DELAY = 5000L +private const val PERCENT_MAX = 100 + +/** Handles firmware updates via USB Mass Storage (UF2). */ +class UsbUpdateHandler +@Inject +constructor( + private val firmwareRetriever: FirmwareRetriever, + private val serviceRepository: ServiceRepository, +) : FirmwareUpdateHandler { + + override suspend fun startUpdate( + release: FirmwareRelease, + hardware: DeviceHardware, + target: String, // Unused for USB + updateState: (FirmwareUpdateState) -> Unit, + firmwareUri: Uri?, + ): File? = + try { + val downloadingMsg = + getString(Res.string.firmware_update_downloading_percent, 0) + .replace(Regex(":?\\s*%1\\\$d%?"), "") + .trim() + + updateState(FirmwareUpdateState.Downloading(ProgressState(message = downloadingMsg, progress = 0f))) + + val rebootingMsg = getString(Res.string.firmware_update_rebooting) + + if (firmwareUri != null) { + updateState(FirmwareUpdateState.Processing(ProgressState(rebootingMsg))) + serviceRepository.meshService?.rebootToDfu() + delay(REBOOT_DELAY) + + updateState(FirmwareUpdateState.AwaitingFileSave(null, "firmware.uf2", firmwareUri)) + null + } else { + val firmwareFile = + firmwareRetriever.retrieveUsbFirmware(release, hardware) { progress -> + val percent = (progress * PERCENT_MAX).toInt() + updateState( + FirmwareUpdateState.Downloading( + ProgressState(message = downloadingMsg, progress = progress, details = "$percent%"), + ), + ) + } + + if (firmwareFile == null) { + val retrievalFailedMsg = getString(Res.string.firmware_update_retrieval_failed) + updateState(FirmwareUpdateState.Error(retrievalFailedMsg)) + null + } else { + updateState(FirmwareUpdateState.Processing(ProgressState(rebootingMsg))) + serviceRepository.meshService?.rebootToDfu() + delay(REBOOT_DELAY) + + updateState(FirmwareUpdateState.AwaitingFileSave(firmwareFile, firmwareFile.name)) + firmwareFile + } + } + } catch (e: CancellationException) { + throw e + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.e(e) { "USB Update failed" } + val usbFailedMsg = getString(Res.string.firmware_update_usb_failed) + updateState(FirmwareUpdateState.Error(e.message ?: usbFailedMsg)) + null + } +} diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt new file mode 100644 index 000000000..2776e42cd --- /dev/null +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransport.kt @@ -0,0 +1,362 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.firmware.ota + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withTimeout +import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic +import no.nordicsemi.kotlin.ble.client.android.CentralManager +import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority +import no.nordicsemi.kotlin.ble.client.android.Peripheral +import no.nordicsemi.kotlin.ble.client.distinctByPeripheral +import no.nordicsemi.kotlin.ble.core.ConnectionState +import no.nordicsemi.kotlin.ble.core.WriteType +import java.util.UUID +import kotlin.time.Duration.Companion.seconds +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.toKotlinUuid + +/** + * BLE transport implementation for ESP32 Unified OTA protocol. Uses Nordic Kotlin-BLE-Library for modern coroutine + * support. + * + * Service UUID: 4FAFC201-1FB5-459E-8FCC-C5C9C331914B + * - OTA Characteristic (Write): 62ec0272-3ec5-11eb-b378-0242ac130005 + * - TX Characteristic (Notify): 62ec0272-3ec5-11eb-b378-0242ac130003 + */ +class BleOtaTransport(private val centralManager: CentralManager, private val address: String) : UnifiedOtaProtocol { + + private val transportScope = CoroutineScope(SupervisorJob()) + private var peripheral: Peripheral? = null + private var otaCharacteristic: RemoteCharacteristic? = null + + private val responseChannel = + kotlinx.coroutines.channels.Channel(kotlinx.coroutines.channels.Channel.BUFFERED) + + private var isConnected = false + + /** + * Scan for the device by MAC address with retries. After reboot, the device needs time to come up in OTA mode. + * + * Note: We scan by address rather than service UUID because some ESP32 OTA bootloaders don't include the service + * UUID in their advertisement data - the service is only discoverable after connecting. We verify the OTA service + * exists after connection. + * + * ESP32 bootloaders may use the original MAC address OR increment the last byte by 1 for OTA mode, so we check both + * addresses. + */ + @OptIn(ExperimentalUuidApi::class) + private suspend fun scanForOtaDevice(): Peripheral? { + // ESP32 OTA bootloader may use MAC address with last byte incremented by 1 + val otaAddress = calculateOtaAddress(address) + val targetAddresses = setOf(address, otaAddress) + Logger.i { "BLE OTA: Will match addresses: $targetAddresses" } + + repeat(SCAN_RETRY_COUNT) { attempt -> + Logger.i { "BLE OTA: Scanning for device (attempt ${attempt + 1}/$SCAN_RETRY_COUNT)..." } + + // Scan without service UUID filter - ESP32 OTA bootloader may not advertise the UUID + // Log all devices found during scan for debugging + val foundDevices = mutableSetOf() + val peripheral = + centralManager + .scan(SCAN_TIMEOUT) + .distinctByPeripheral() + .map { it.peripheral } + .onEach { p -> + if (foundDevices.add(p.address)) { + Logger.d { "BLE OTA: Scan found device: ${p.address} (name=${p.name})" } + } + } + .firstOrNull { it.address in targetAddresses } + + if (peripheral != null) { + Logger.i { "BLE OTA: Found target device at ${peripheral.address}" } + return peripheral + } + + Logger.w { "BLE OTA: Target addresses $targetAddresses not in ${foundDevices.size} devices found" } + + if (attempt < SCAN_RETRY_COUNT - 1) { + Logger.i { "BLE OTA: Device not found, waiting ${SCAN_RETRY_DELAY_MS}ms before retry..." } + kotlinx.coroutines.delay(SCAN_RETRY_DELAY_MS) + } + } + return null + } + + /** + * Calculate the potential OTA MAC address by incrementing the last byte. Some ESP32 bootloaders use MAC+1 for OTA + * mode to distinguish from normal operation. + */ + @Suppress("MagicNumber", "ReturnCount") + private fun calculateOtaAddress(macAddress: String): String { + val parts = macAddress.split(":") + if (parts.size != 6) return macAddress + + val lastByte = parts[5].toIntOrNull(16) ?: return macAddress + val incrementedByte = ((lastByte + 1) and 0xFF).toString(16).uppercase().padStart(2, '0') + return parts.take(5).joinToString(":") + ":" + incrementedByte + } + + /** Connect to the device and discover OTA service. */ + @OptIn(ExperimentalUuidApi::class) + @Suppress("LongMethod") + override suspend fun connect(): Result = runCatching { + Logger.i { "BLE OTA: Waiting ${REBOOT_DELAY_MS}ms for device to reboot into OTA mode..." } + kotlinx.coroutines.delay(REBOOT_DELAY_MS) + + Logger.i { "BLE OTA: Connecting to $address using Nordic BLE Library..." } + + // Scan for device by address - device must have rebooted into OTA mode + val p = + scanForOtaDevice() + ?: throw OtaProtocolException.ConnectionFailed( + "Device not found at address $address. " + + "Ensure the device has rebooted into OTA mode and is advertising.", + ) + + peripheral = p + + centralManager.connect( + peripheral = p, + options = CentralManager.ConnectionOptions.AutoConnect(automaticallyRequestHighestValueLength = true), + ) + p.requestConnectionPriority(ConnectionPriority.HIGH) + + // Monitor connection state + p.state + .onEach { state -> + Logger.d { "BLE OTA: Connection state changed to $state" } + if (state is ConnectionState.Disconnected) { + isConnected = false + } + } + .launchIn(transportScope) + + // Wait for connection or failure with timeout + // Don't use drop(1) - we might already be connected by the time we start collecting + val connectionState = + try { + withTimeout(CONNECTION_TIMEOUT_MS) { + p.state.first { it is ConnectionState.Connected || it is ConnectionState.Disconnected } + } + } catch (@Suppress("SwallowedException") e: kotlinx.coroutines.TimeoutCancellationException) { + Logger.w { "BLE OTA: Timed out waiting to connect to ${p.address}. Error: ${e.message}" } + throw OtaProtocolException.Timeout("Timed out connecting to device at address ${p.address}") + } + + if (connectionState is ConnectionState.Disconnected) { + Logger.w { "BLE OTA: Failed to connect to ${p.address} (state=$connectionState)" } + throw OtaProtocolException.ConnectionFailed("Failed to connect to device at address ${p.address}") + } + + Logger.i { "BLE OTA: Connected to ${p.address}, discovering services..." } + + // Discover services + val services = p.services(listOf(SERVICE_UUID.toKotlinUuid())).filterNotNull().first() + val meshtasticOtaService = + services.find { it.uuid == SERVICE_UUID.toKotlinUuid() } + ?: throw OtaProtocolException.ConnectionFailed("ESP32 OTA service not found") + + otaCharacteristic = + meshtasticOtaService.characteristics.find { it.uuid == OTA_CHARACTERISTIC_UUID.toKotlinUuid() } + val txChar = meshtasticOtaService.characteristics.find { it.uuid == TX_CHARACTERISTIC_UUID.toKotlinUuid() } + + if (otaCharacteristic == null || txChar == null) { + throw OtaProtocolException.ConnectionFailed("Required characteristics not found") + } + + // Enable notifications and collect responses + txChar + .subscribe() + .onEach { notifyBytes -> + try { + val response = notifyBytes.decodeToString() + Logger.d { "BLE OTA: Received response: $response" } + responseChannel.trySend(response) + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.e(e) { "BLE OTA: Failed to decode response bytes" } + } + } + .launchIn(transportScope) + + isConnected = true + Logger.i { "BLE OTA: Service discovered and ready" } + } + + override suspend fun startOta( + sizeBytes: Long, + sha256Hash: String, + onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit, + ): Result = runCatching { + val command = OtaCommand.StartOta(sizeBytes, sha256Hash) + sendCommand(command) + + var handshakeComplete = false + while (!handshakeComplete) { + val response = waitForResponse(ERASING_TIMEOUT_MS) + when (val parsed = OtaResponse.parse(response)) { + is OtaResponse.Ok -> handshakeComplete = true + is OtaResponse.Erasing -> { + Logger.i { "BLE OTA: Device erasing flash..." } + onHandshakeStatus(OtaHandshakeStatus.Erasing) + } + + is OtaResponse.Error -> { + if (parsed.message.contains("Hash Rejected", ignoreCase = true)) { + throw OtaProtocolException.HashRejected(sha256Hash) + } + throw OtaProtocolException.CommandFailed(command, parsed) + } + + else -> { + Logger.w { "BLE OTA: Unexpected handshake response: $response" } + } + } + } + } + + override suspend fun streamFirmware( + data: ByteArray, + chunkSize: Int, + onProgress: suspend (Float) -> Unit, + ): Result = runCatching { + val totalBytes = data.size + var sentBytes = 0 + + while (sentBytes < totalBytes) { + if (!isConnected) { + throw OtaProtocolException.TransferFailed("Connection lost during transfer") + } + + val remainingBytes = totalBytes - sentBytes + val currentChunkSize = minOf(chunkSize, remainingBytes) + val chunk = data.copyOfRange(sentBytes, sentBytes + currentChunkSize) + + // Write chunk + writeData(chunk, WriteType.WITHOUT_RESPONSE) + + // Wait for response (ACK or OK for last chunk) + val response = waitForResponse(ACK_TIMEOUT_MS) + val nextSentBytes = sentBytes + currentChunkSize + when (val parsed = OtaResponse.parse(response)) { + is OtaResponse.Ack -> { + // Normal chunk success + } + + is OtaResponse.Ok -> { + // OK indicates completion (usually on last chunk) + if (nextSentBytes >= totalBytes) { + sentBytes = nextSentBytes + onProgress(1.0f) + return@runCatching Unit + } else { + throw OtaProtocolException.TransferFailed("Premature OK received at offset $nextSentBytes") + } + } + + is OtaResponse.Error -> { + if (parsed.message.contains("Hash Mismatch", ignoreCase = true)) { + throw OtaProtocolException.VerificationFailed("Firmware hash mismatch after transfer") + } + throw OtaProtocolException.TransferFailed("Transfer failed: ${parsed.message}") + } + + else -> throw OtaProtocolException.TransferFailed("Unexpected response: $response") + } + + sentBytes = nextSentBytes + onProgress(sentBytes.toFloat() / totalBytes) + } + + // If we finished the loop without receiving OK, wait for it now + val finalResponse = waitForResponse(VERIFICATION_TIMEOUT_MS) + when (val parsed = OtaResponse.parse(finalResponse)) { + is OtaResponse.Ok -> Unit + is OtaResponse.Error -> { + if (parsed.message.contains("Hash Mismatch", ignoreCase = true)) { + throw OtaProtocolException.VerificationFailed("Firmware hash mismatch after transfer") + } + throw OtaProtocolException.TransferFailed("Verification failed: ${parsed.message}") + } + + else -> throw OtaProtocolException.TransferFailed("Expected OK after transfer, got: $parsed") + } + } + + override suspend fun close() { + peripheral?.disconnect() + peripheral = null + isConnected = false + transportScope.cancel() + } + + private suspend fun sendCommand(command: OtaCommand) { + val data = command.toString().toByteArray() + writeData(data, WriteType.WITH_RESPONSE) + } + + private suspend fun writeData(data: ByteArray, writeType: WriteType) { + val characteristic = + otaCharacteristic ?: throw OtaProtocolException.ConnectionFailed("OTA characteristic not available") + + try { + characteristic.write(data, writeType = writeType) + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + throw OtaProtocolException.TransferFailed("Failed to write data", e) + } + } + + private suspend fun waitForResponse(timeoutMs: Long): String = try { + withTimeout(timeoutMs) { responseChannel.receive() } + } catch (@Suppress("SwallowedException") e: kotlinx.coroutines.TimeoutCancellationException) { + throw OtaProtocolException.Timeout("Timeout waiting for response after ${timeoutMs}ms") + } + + companion object { + // Service and Characteristic UUIDs from ESP32 Unified OTA spec + private val SERVICE_UUID = UUID.fromString("4FAFC201-1FB5-459E-8FCC-C5C9C331914B") + private val OTA_CHARACTERISTIC_UUID = UUID.fromString("62ec0272-3ec5-11eb-b378-0242ac130005") + private val TX_CHARACTERISTIC_UUID = UUID.fromString("62ec0272-3ec5-11eb-b378-0242ac130003") + + // Timeouts and retries + private val SCAN_TIMEOUT = 10.seconds + private const val CONNECTION_TIMEOUT_MS = 15_000L + private const val ERASING_TIMEOUT_MS = 60_000L // Flash erase can take a while + private const val ACK_TIMEOUT_MS = 10_000L + private const val VERIFICATION_TIMEOUT_MS = 10_000L + + // Reboot and scan retry configuration + // Device needs time to reboot into OTA mode after receiving the reboot command + private const val REBOOT_DELAY_MS = 5_000L + private const val SCAN_RETRY_COUNT = 3 + private const val SCAN_RETRY_DELAY_MS = 2_000L + + // Recommended chunk size for BLE + const val RECOMMENDED_CHUNK_SIZE = 512 + } +} diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt new file mode 100644 index 000000000..c3c89d3a5 --- /dev/null +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt @@ -0,0 +1,343 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.firmware.ota + +import android.content.Context +import android.net.Uri +import co.touchlab.kermit.Logger +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import no.nordicsemi.kotlin.ble.client.android.CentralManager +import org.jetbrains.compose.resources.getString +import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.firmware_update_connecting_attempt +import org.meshtastic.core.strings.firmware_update_downloading_percent +import org.meshtastic.core.strings.firmware_update_erasing +import org.meshtastic.core.strings.firmware_update_hash_rejected +import org.meshtastic.core.strings.firmware_update_loading +import org.meshtastic.core.strings.firmware_update_ota_failed +import org.meshtastic.core.strings.firmware_update_retrieval_failed +import org.meshtastic.core.strings.firmware_update_starting_ota +import org.meshtastic.core.strings.firmware_update_uploading +import org.meshtastic.core.strings.firmware_update_waiting_reboot +import org.meshtastic.feature.firmware.FirmwareRetriever +import org.meshtastic.feature.firmware.FirmwareUpdateHandler +import org.meshtastic.feature.firmware.FirmwareUpdateState +import org.meshtastic.feature.firmware.ProgressState +import java.io.File +import javax.inject.Inject + +private const val RETRY_DELAY = 2000L +private const val PERCENT_MAX = 100 +private const val KIB_DIVISOR = 1024f +private const val MILLIS_PER_SECOND = 1000f + +// 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 Android BLE GATT to fully release after disconnecting mesh service +private const val GATT_RELEASE_DELAY_MS = 1000L + +/** + * Handler for ESP32 firmware updates using the Unified OTA protocol. Supports both BLE and WiFi/TCP transports via + * UnifiedOtaProtocol. + */ +@Suppress("TooManyFunctions") +class Esp32OtaUpdateHandler +@Inject +constructor( + private val firmwareRetriever: FirmwareRetriever, + private val serviceRepository: ServiceRepository, + private val centralManager: CentralManager, + @ApplicationContext private val context: Context, +) : FirmwareUpdateHandler { + + /** Entry point for FirmwareUpdateHandler interface. Decides between BLE and WiFi based on target format. */ + override suspend fun startUpdate( + release: FirmwareRelease, + hardware: DeviceHardware, + target: String, + updateState: (FirmwareUpdateState) -> Unit, + firmwareUri: Uri?, + ): File? = if (target.contains(":")) { + startBleUpdate(release, hardware, target, updateState, firmwareUri) + } else { + startWifiUpdate(release, hardware, target, updateState, firmwareUri) + } + + private suspend fun startBleUpdate( + release: FirmwareRelease, + hardware: DeviceHardware, + address: String, + updateState: (FirmwareUpdateState) -> Unit, + firmwareUri: Uri? = null, + ): File? = performUpdate( + release = release, + hardware = hardware, + updateState = updateState, + firmwareUri = firmwareUri, + transportFactory = { BleOtaTransport(centralManager, address) }, + rebootMode = 1, + connectionAttempts = 5, + ) + + private suspend fun startWifiUpdate( + release: FirmwareRelease, + hardware: DeviceHardware, + deviceIp: String, + updateState: (FirmwareUpdateState) -> Unit, + firmwareUri: Uri? = null, + ): File? = performUpdate( + release = release, + hardware = hardware, + updateState = updateState, + firmwareUri = firmwareUri, + transportFactory = { WifiOtaTransport(deviceIp, WifiOtaTransport.DEFAULT_PORT) }, + rebootMode = 2, + connectionAttempts = 10, + ) + + private suspend fun performUpdate( + release: FirmwareRelease, + hardware: DeviceHardware, + updateState: (FirmwareUpdateState) -> Unit, + firmwareUri: Uri?, + transportFactory: () -> UnifiedOtaProtocol, + rebootMode: Int, + connectionAttempts: Int, + ): File? = try { + withContext(Dispatchers.IO) { + // Step 1: Get firmware file + val firmwareFile = + obtainFirmwareFile(release, hardware, firmwareUri, updateState) ?: return@withContext null + + // Step 2: Calculate Hash and Trigger Reboot + val sha256Bytes = FirmwareHashUtil.calculateSha256Bytes(firmwareFile) + val sha256Hash = FirmwareHashUtil.bytesToHex(sha256Bytes) + Logger.i { "ESP32 OTA: Firmware hash: $sha256Hash" } + 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() + if (!connectToDevice(transport, connectionAttempts, updateState)) return@withContext null + + try { + executeOtaSequence(transport, firmwareFile, sha256Hash, rebootMode, updateState) + firmwareFile + } finally { + transport.close() + } + } + } catch (e: CancellationException) { + throw e + } catch (e: OtaProtocolException.HashRejected) { + Logger.e(e) { "ESP32 OTA: Hash rejected by device" } + val msg = getString(Res.string.firmware_update_hash_rejected) + updateState(FirmwareUpdateState.Error(msg)) + null + } catch (e: OtaProtocolException) { + Logger.e(e) { "ESP32 OTA: Protocol error" } + val msg = getString(Res.string.firmware_update_ota_failed, e.message ?: "") + updateState(FirmwareUpdateState.Error(msg)) + null + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.e(e) { "ESP32 OTA: Unexpected error" } + val msg = getString(Res.string.firmware_update_ota_failed, e.message ?: "") + updateState(FirmwareUpdateState.Error(msg)) + null + } + + private suspend fun downloadFirmware( + release: FirmwareRelease, + hardware: DeviceHardware, + updateState: (FirmwareUpdateState) -> Unit, + ): File? { + val downloadingMsg = + getString(Res.string.firmware_update_downloading_percent, 0).replace(Regex(":?\\s*%1\\\$d%?"), "").trim() + updateState(FirmwareUpdateState.Downloading(ProgressState(message = downloadingMsg, progress = 0f))) + return firmwareRetriever.retrieveEsp32Firmware(release, hardware) { progress -> + val percent = (progress * PERCENT_MAX).toInt() + updateState( + FirmwareUpdateState.Downloading( + ProgressState(message = downloadingMsg, progress = progress, details = "$percent%"), + ), + ) + } + } + + private suspend fun getFirmwareFromUri(uri: Uri): File? = withContext(Dispatchers.IO) { + val inputStream = context.contentResolver.openInputStream(uri) ?: return@withContext null + val tempFile = File(context.cacheDir, "firmware_update/ota_firmware.bin") + tempFile.parentFile?.mkdirs() + inputStream.use { input -> tempFile.outputStream().use { output -> input.copyTo(output) } } + tempFile + } + + private fun triggerRebootOta(mode: Int, hash: ByteArray?) { + val service = serviceRepository.meshService ?: return + try { + val myInfo = service.getMyNodeInfo() ?: return + Logger.i { "ESP32 OTA: Triggering reboot OTA mode $mode with hash" } + service.requestRebootOta(service.getPacketId(), myInfo.myNodeNum, mode, hash) + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.e(e) { "ESP32 OTA: Failed to trigger reboot OTA" } + } + } + + /** + * Disconnect the mesh service BLE connection to free up the GATT for OTA. Setting device address to "n" (NOP + * interface) cleanly disconnects without reconnection attempts. + */ + private fun disconnectMeshService() { + try { + Logger.i { "ESP32 OTA: Disconnecting mesh service for OTA" } + serviceRepository.meshService?.setDeviceAddress("n") + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.w(e) { "ESP32 OTA: Error disconnecting mesh service" } + } + } + + private suspend fun obtainFirmwareFile( + release: FirmwareRelease, + hardware: DeviceHardware, + firmwareUri: Uri?, + updateState: (FirmwareUpdateState) -> Unit, + ): File? { + val firmwareFile = + if (firmwareUri != null) { + val loadingMsg = getString(Res.string.firmware_update_loading) + updateState(FirmwareUpdateState.Processing(ProgressState(loadingMsg))) + getFirmwareFromUri(firmwareUri) + } else { + downloadFirmware(release, hardware, updateState) + } + + if (firmwareFile == null) { + val retrievalFailedMsg = getString(Res.string.firmware_update_retrieval_failed) + updateState(FirmwareUpdateState.Error(retrievalFailedMsg)) + return null + } + return firmwareFile + } + + private suspend fun connectToDevice( + transport: UnifiedOtaProtocol, + attempts: Int, + updateState: (FirmwareUpdateState) -> Unit, + ): Boolean { + // Show "waiting for reboot" state before first connection attempt + val waitingMsg = getString(Res.string.firmware_update_waiting_reboot) + updateState(FirmwareUpdateState.Processing(ProgressState(waitingMsg))) + + for (i in 1..attempts) { + try { + val connectingMsg = getString(Res.string.firmware_update_connecting_attempt, i, attempts) + updateState(FirmwareUpdateState.Processing(ProgressState(connectingMsg))) + transport.connect().getOrThrow() + return true + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + if (i == attempts) throw e + delay(RETRY_DELAY) + } + } + return false + } + + @Suppress("LongMethod") + private suspend fun executeOtaSequence( + transport: UnifiedOtaProtocol, + firmwareFile: File, + sha256Hash: String, + rebootMode: Int, + updateState: (FirmwareUpdateState) -> Unit, + ) { + // Step 5: Start OTA + val startingOtaMsg = getString(Res.string.firmware_update_starting_ota) + updateState(FirmwareUpdateState.Processing(ProgressState(startingOtaMsg))) + transport + .startOta(sizeBytes = firmwareFile.length(), sha256Hash = sha256Hash) { status -> + when (status) { + OtaHandshakeStatus.Erasing -> { + val erasingMsg = getString(Res.string.firmware_update_erasing) + updateState(FirmwareUpdateState.Processing(ProgressState(erasingMsg))) + } + } + } + .getOrThrow() + + // Step 6: Stream + val uploadingMsg = getString(Res.string.firmware_update_uploading) + updateState(FirmwareUpdateState.Updating(ProgressState(uploadingMsg, 0f))) + val firmwareData = firmwareFile.readBytes() + val chunkSize = + if (rebootMode == 1) { + BleOtaTransport.RECOMMENDED_CHUNK_SIZE + } else { + WifiOtaTransport.RECOMMENDED_CHUNK_SIZE + } + + val startTime = System.currentTimeMillis() + transport + .streamFirmware( + data = firmwareData, + chunkSize = chunkSize, + onProgress = { progress -> + val currentTime = System.currentTimeMillis() + val elapsedSeconds = (currentTime - startTime) / MILLIS_PER_SECOND + val percent = (progress * PERCENT_MAX).toInt() + + val speedText = + if (elapsedSeconds > 0) { + val bytesSent = (progress * firmwareData.size).toLong() + val kibPerSecond = (bytesSent / KIB_DIVISOR) / elapsedSeconds + val remainingBytes = firmwareData.size - bytesSent + val etaSeconds = if (kibPerSecond > 0) (remainingBytes / KIB_DIVISOR) / kibPerSecond else 0f + + String.format(java.util.Locale.US, "%.1f KiB/s, ETA: %ds", kibPerSecond, etaSeconds.toInt()) + } else { + "" + } + + updateState( + FirmwareUpdateState.Updating( + ProgressState( + message = uploadingMsg, + progress = progress, + details = "$percent% ($speedText)", + ), + ), + ) + }, + ) + .getOrThrow() + Logger.i { "ESP32 OTA: Firmware stream completed" } + + updateState(FirmwareUpdateState.Success) + } +} diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/FirmwareHashUtil.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/FirmwareHashUtil.kt new file mode 100644 index 000000000..46f33ec3a --- /dev/null +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/FirmwareHashUtil.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.firmware.ota + +import java.io.File +import java.io.FileInputStream +import java.security.MessageDigest + +/** Utility functions for firmware hash calculation. */ +object FirmwareHashUtil { + + private const val BUFFER_SIZE = 8192 + + /** + * Calculate SHA-256 hash of a file as a byte array. + * + * @param file Firmware file to hash + * @return 32-byte SHA-256 hash + */ + fun calculateSha256Bytes(file: File): ByteArray { + val digest = MessageDigest.getInstance("SHA-256") + FileInputStream(file).use { fis -> + val buffer = ByteArray(BUFFER_SIZE) + var bytesRead: Int + while (fis.read(buffer).also { bytesRead = it } != -1) { + digest.update(buffer, 0, bytesRead) + } + } + return digest.digest() + } + + /** Convert byte array to hex string. */ + fun bytesToHex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it) } +} diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/UnifiedOtaProtocol.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/UnifiedOtaProtocol.kt new file mode 100644 index 000000000..893278fbd --- /dev/null +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/UnifiedOtaProtocol.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.firmware.ota + +/** Commands supported by the ESP32 Unified OTA protocol. All commands are text-based and terminated with '\n'. */ +sealed class OtaCommand { + /** Start OTA update with firmware size and SHA-256 hash */ + data class StartOta(val sizeBytes: Long, val sha256Hash: String) : OtaCommand() { + override fun toString() = "OTA $sizeBytes $sha256Hash\n" + } +} + +/** Responses from the ESP32 Unified OTA protocol. */ +sealed class OtaResponse { + /** Successful response with optional data */ + data class Ok( + val hwVersion: String? = null, + val fwVersion: String? = null, + val rebootCount: Int? = null, + val gitHash: String? = null, + ) : OtaResponse() + + /** Device is erasing flash partition (sent before OK after OTA command) */ + data object Erasing : OtaResponse() + + /** Acknowledgment for received data chunk (BLE only) */ + data object Ack : OtaResponse() + + /** Error response with message */ + data class Error(val message: String) : OtaResponse() + + companion object { + private const val OK_PREFIX_LENGTH = 3 + private const val ERR_PREFIX_LENGTH = 4 + private const val VERSION_PARTS_COUNT = 4 + + /** + * Parse a response string from the device. Format examples: + * - "OK\n" + * - "OK 1 2.3.4 45 v2.3.4-abc123\n" + * - "ERASING\n" + * - "ACK\n" + * - "ERR Hash Rejected\n" + */ + fun parse(response: String): OtaResponse { + val trimmed = response.trim() + + return when { + trimmed == "OK" -> Ok() + trimmed.startsWith("OK ") -> { + val parts = trimmed.substring(OK_PREFIX_LENGTH).split(" ") + when (parts.size) { + VERSION_PARTS_COUNT -> + Ok( + hwVersion = parts[0], + fwVersion = parts[1], + rebootCount = parts[2].toIntOrNull(), + gitHash = parts[3], + ) + else -> Ok() + } + } + trimmed == "ERASING" -> Erasing + trimmed == "ACK" -> Ack + trimmed.startsWith("ERR ") -> Error(trimmed.substring(ERR_PREFIX_LENGTH)) + trimmed == "ERR" -> Error("Unknown error") + else -> Error("Unknown response: $trimmed") + } + } + } +} + +/** Status updates during the OTA handshake. */ +sealed class OtaHandshakeStatus { + /** The device is erasing the flash partition. */ + data object Erasing : OtaHandshakeStatus() +} + +/** Interface for ESP32 Unified OTA protocol implementation. Supports both BLE and WiFi/TCP transports. */ +interface UnifiedOtaProtocol { + /** + * Connect to the device and discover OTA service/establish connection. + * + * @return Success if connected and ready, error otherwise + */ + suspend fun connect(): Result + + /** + * Start OTA update process. + * + * @param sizeBytes Total firmware size in bytes + * @param sha256Hash SHA-256 hash of the firmware (64 hex characters) + * @param onHandshakeStatus Optional callback to report status changes (e.g., "Erasing...") + * @return Success if device accepts and is ready, error otherwise + */ + suspend fun startOta( + sizeBytes: Long, + sha256Hash: String, + onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit = {}, + ): Result + + /** + * Stream firmware binary data to the device. + * + * @param data Complete firmware binary + * @param chunkSize Size of each chunk to send (256-512 for BLE, up to 1024 for WiFi) + * @param onProgress Progress callback (0.0 to 1.0) + * @return Success if all data transferred and verified, error otherwise + */ + suspend fun streamFirmware(data: ByteArray, chunkSize: Int, onProgress: suspend (Float) -> Unit): Result + + /** Close the connection and cleanup resources. */ + suspend fun close() +} + +/** Exception thrown during OTA protocol operations. */ +sealed class OtaProtocolException(message: String, cause: Throwable? = null) : Exception(message, cause) { + class ConnectionFailed(message: String, cause: Throwable? = null) : OtaProtocolException(message, cause) + + class CommandFailed(val command: OtaCommand, val response: OtaResponse.Error) : + OtaProtocolException("Command $command failed: ${response.message}") + + class HashRejected(val providedHash: String) : + OtaProtocolException("Device rejected hash: $providedHash (NVS mismatch)") + + class TransferFailed(message: String, cause: Throwable? = null) : OtaProtocolException(message, cause) + + class VerificationFailed(message: String) : OtaProtocolException(message) + + class Timeout(message: String) : OtaProtocolException(message) +} diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt new file mode 100644 index 000000000..7e78445ef --- /dev/null +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt @@ -0,0 +1,291 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.firmware.ota + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.net.SocketTimeoutException + +/** + * WiFi/TCP transport implementation for ESP32 Unified OTA protocol. + * + * Uses UDP for device discovery on port 3232, then establishes TCP connection for OTA commands and firmware streaming. + * + * Unlike BLE, WiFi transport: + * - Uses synchronous TCP (no manual ACK waiting) + * - Supports larger chunk sizes (up to 1024 bytes) + * - Generally faster transfer speeds + */ +class WifiOtaTransport(private val deviceIpAddress: String, private val port: Int = DEFAULT_PORT) : UnifiedOtaProtocol { + + private var socket: Socket? = null + private var writer: OutputStreamWriter? = null + private var reader: BufferedReader? = null + private var isConnected = false + + /** Connect to the device via TCP. */ + override suspend fun connect(): Result = withContext(Dispatchers.IO) { + runCatching { + Logger.i { "WiFi OTA: Connecting to $deviceIpAddress:$port" } + + socket = + Socket().apply { + soTimeout = SOCKET_TIMEOUT_MS + connect( + InetSocketAddress(deviceIpAddress, this@WifiOtaTransport.port), + CONNECTION_TIMEOUT_MS, + ) + } + + writer = OutputStreamWriter(socket!!.getOutputStream(), Charsets.UTF_8) + reader = BufferedReader(InputStreamReader(socket!!.getInputStream(), Charsets.UTF_8)) + isConnected = true + + Logger.i { "WiFi OTA: Connected successfully" } + } + .onFailure { e -> + Logger.e(e) { "WiFi OTA: Connection failed" } + close() + } + } + + override suspend fun startOta( + sizeBytes: Long, + sha256Hash: String, + onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit, + ): Result = runCatching { + val command = OtaCommand.StartOta(sizeBytes, sha256Hash) + sendCommand(command) + + var handshakeComplete = false + while (!handshakeComplete) { + val response = readResponse(ERASING_TIMEOUT_MS) + when (val parsed = OtaResponse.parse(response)) { + is OtaResponse.Ok -> handshakeComplete = true + is OtaResponse.Erasing -> { + Logger.i { "WiFi OTA: Device erasing flash..." } + onHandshakeStatus(OtaHandshakeStatus.Erasing) + } + + is OtaResponse.Error -> { + if (parsed.message.contains("Hash Rejected", ignoreCase = true)) { + throw OtaProtocolException.HashRejected(sha256Hash) + } + throw OtaProtocolException.CommandFailed(command, parsed) + } + + else -> { + Logger.w { "WiFi OTA: Unexpected handshake response: $response" } + } + } + } + } + + @Suppress("CyclomaticComplexMethod") + override suspend fun streamFirmware( + data: ByteArray, + chunkSize: Int, + onProgress: suspend (Float) -> Unit, + ): Result = withContext(Dispatchers.IO) { + runCatching { + if (!isConnected) { + throw OtaProtocolException.TransferFailed("Not connected") + } + + val totalBytes = data.size + var sentBytes = 0 + val outputStream = socket!!.getOutputStream() + + while (sentBytes < totalBytes) { + val remainingBytes = totalBytes - sentBytes + val currentChunkSize = minOf(chunkSize, remainingBytes) + val chunk = data.copyOfRange(sentBytes, sentBytes + currentChunkSize) + + // Write chunk directly to TCP stream + outputStream.write(chunk) + outputStream.flush() + + // In the updated protocol, the device may send ACKs over WiFi too. + // We check for any available responses without blocking too long. + if (reader?.ready() == true) { + val response = readResponse(ACK_TIMEOUT_MS) + val nextSentBytes = sentBytes + currentChunkSize + when (val parsed = OtaResponse.parse(response)) { + is OtaResponse.Ack -> { + // Normal chunk success + } + + is OtaResponse.Ok -> { + // OK indicates completion (usually on last chunk) + if (nextSentBytes >= totalBytes) { + sentBytes = nextSentBytes + onProgress(1.0f) + return@runCatching Unit + } + } + + is OtaResponse.Error -> { + throw OtaProtocolException.TransferFailed("Transfer failed: ${parsed.message}") + } + + else -> {} // Ignore other responses during stream + } + } + + sentBytes += currentChunkSize + onProgress(sentBytes.toFloat() / totalBytes) + + // Small delay to avoid overwhelming the device + delay(WRITE_DELAY_MS) + } + + Logger.i { "WiFi OTA: Firmware streaming complete ($sentBytes bytes)" } + + // Wait for final verification response (loop until OK or Error) + var finalHandshakeComplete = false + while (!finalHandshakeComplete) { + val finalResponse = readResponse(VERIFICATION_TIMEOUT_MS) + when (val parsed = OtaResponse.parse(finalResponse)) { + is OtaResponse.Ok -> finalHandshakeComplete = true + is OtaResponse.Ack -> {} // Ignore late ACKs + is OtaResponse.Error -> { + if (parsed.message.contains("Hash Mismatch", ignoreCase = true)) { + throw OtaProtocolException.VerificationFailed("Firmware hash mismatch after transfer") + } + throw OtaProtocolException.TransferFailed("Verification failed: ${parsed.message}") + } + + else -> + throw OtaProtocolException.TransferFailed("Expected OK after transfer, got: $finalResponse") + } + } + } + } + + override suspend fun close() { + withContext(Dispatchers.IO) { + runCatching { + writer?.close() + reader?.close() + socket?.close() + } + writer = null + reader = null + socket = null + isConnected = false + } + } + + private suspend fun sendCommand(command: OtaCommand) = withContext(Dispatchers.IO) { + val w = writer ?: throw OtaProtocolException.ConnectionFailed("Not connected") + val commandStr = command.toString() + Logger.d { "WiFi OTA: Sending command: ${commandStr.trim()}" } + w.write(commandStr) + w.flush() + } + + private suspend fun readResponse(timeoutMs: Long = COMMAND_TIMEOUT_MS): String = withContext(Dispatchers.IO) { + try { + withTimeout(timeoutMs) { + val r = reader ?: throw OtaProtocolException.ConnectionFailed("Not connected") + val response = r.readLine() ?: throw OtaProtocolException.ConnectionFailed("Connection closed") + Logger.d { "WiFi OTA: Received response: $response" } + response + } + } catch (@Suppress("SwallowedException") e: SocketTimeoutException) { + throw OtaProtocolException.Timeout("Timeout waiting for response after ${timeoutMs}ms") + } + } + + companion object { + const val DEFAULT_PORT = 3232 + const val RECOMMENDED_CHUNK_SIZE = 1024 // Larger than BLE + private const val RECEIVE_BUFFER_SIZE = 1024 + private const val DISCOVERY_TIMEOUT_DEFAULT = 3000L + private const val BROADCAST_ADDRESS = "255.255.255.255" + + // Timeouts + private const val CONNECTION_TIMEOUT_MS = 5_000 + private const val SOCKET_TIMEOUT_MS = 15_000 + private const val COMMAND_TIMEOUT_MS = 10_000L + private const val ERASING_TIMEOUT_MS = 60_000L + private const val ACK_TIMEOUT_MS = 10_000L + private const val VERIFICATION_TIMEOUT_MS = 10_000L + private const val WRITE_DELAY_MS = 10L // Shorter than BLE + + /** + * Discover ESP32 devices on the local network via UDP broadcast. + * + * @return List of discovered device IP addresses + */ + suspend fun discoverDevices(timeoutMs: Long = DISCOVERY_TIMEOUT_DEFAULT): List = + withContext(Dispatchers.IO) { + val devices = mutableListOf() + + runCatching { + DatagramSocket().use { socket -> + socket.broadcast = true + socket.soTimeout = timeoutMs.toInt() + + // Send discovery broadcast + val discoveryMessage = "MESHTASTIC_OTA_DISCOVERY\n".toByteArray() + val broadcastAddress = InetAddress.getByName(BROADCAST_ADDRESS) + val packet = + DatagramPacket(discoveryMessage, discoveryMessage.size, broadcastAddress, DEFAULT_PORT) + socket.send(packet) + Logger.d { "WiFi OTA: Sent discovery broadcast" } + + // Listen for responses + val receiveBuffer = ByteArray(RECEIVE_BUFFER_SIZE) + val startTime = System.currentTimeMillis() + + while (System.currentTimeMillis() - startTime < timeoutMs) { + try { + val receivePacket = DatagramPacket(receiveBuffer, receiveBuffer.size) + socket.receive(receivePacket) + + val response = String(receivePacket.data, 0, receivePacket.length).trim() + if (response.startsWith("MESHTASTIC_OTA")) { + val deviceIp = receivePacket.address.hostAddress + if (deviceIp != null && !devices.contains(deviceIp)) { + devices.add(deviceIp) + Logger.i { "WiFi OTA: Discovered device at $deviceIp" } + } + } + } catch (@Suppress("SwallowedException") e: SocketTimeoutException) { + break + } + } + } + } + .onFailure { e -> Logger.e(e) { "WiFi OTA: Discovery failed" } } + + devices + } + } +} diff --git a/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/FirmwareRetrieverTest.kt b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/FirmwareRetrieverTest.kt new file mode 100644 index 000000000..2bd944b86 --- /dev/null +++ b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/FirmwareRetrieverTest.kt @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.firmware + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.model.DeviceHardware +import java.io.File + +class FirmwareRetrieverTest { + + private val fileHandler: FirmwareFileHandler = mockk() + private val retriever = FirmwareRetriever(fileHandler) + + @Test + fun `retrieveEsp32Firmware uses mt-arch-ota bin when Unified OTA is supported`() = runTest { + val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/esp32.zip") + val hardware = + DeviceHardware( + hwModelSlug = "HELTEC_V3", + platformioTarget = "heltec-v3", + architecture = "esp32-s3", + supportsUnifiedOta = true, + ) + val expectedFile = File("mt-esp32s3-ota.bin") + + coEvery { fileHandler.checkUrlExists(any()) } returns true + coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile + + val result = retriever.retrieveEsp32Firmware(release, hardware) {} + + assertEquals(expectedFile, result) + coVerify { + fileHandler.checkUrlExists( + "https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/mt-esp32s3-ota.bin", + ) + fileHandler.downloadFile( + "https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/mt-esp32s3-ota.bin", + "mt-esp32s3-ota.bin", + any(), + ) + } + } + + @Test + fun `retrieveEsp32Firmware falls back to board-specific bin when mt-arch-ota bin is missing`() = runTest { + val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/esp32.zip") + val hardware = + DeviceHardware( + hwModelSlug = "HELTEC_V3", + platformioTarget = "heltec-v3", + architecture = "esp32-s3", + supportsUnifiedOta = true, + ) + val expectedFile = File("firmware-heltec-v3-2.5.0.bin") + + // First check for mt-esp32s3-ota.bin fails + coEvery { fileHandler.checkUrlExists(match { it.contains("mt-esp32s3-ota.bin") }) } returns false + // ZIP download fails too for the OTA attempt to reach second retrieve call + coEvery { fileHandler.downloadFile(any(), "firmware_release.zip", any()) } returns null + + // Second check for board-specific bin succeeds + coEvery { fileHandler.checkUrlExists(match { it.contains("firmware-heltec-v3") }) } returns true + coEvery { fileHandler.downloadFile(any(), "firmware-heltec-v3-2.5.0.bin", any()) } returns expectedFile + coEvery { fileHandler.extractFirmware(any(), any(), any(), any()) } returns null + + val result = retriever.retrieveEsp32Firmware(release, hardware) {} + + assertEquals(expectedFile, result) + coVerify { + fileHandler.checkUrlExists( + "https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/mt-esp32s3-ota.bin", + ) + fileHandler.checkUrlExists( + "https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/firmware-heltec-v3-2.5.0.bin", + ) + } + } + + @Test + fun `retrieveEsp32Firmware uses legacy filename for devices without Unified OTA`() = runTest { + val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/esp32.zip") + val hardware = + DeviceHardware( + hwModelSlug = "TLORA_V2", + platformioTarget = "tlora-v2", + architecture = "esp32", + supportsUnifiedOta = false, + ) + val expectedFile = File("firmware-tlora-v2-2.5.0.bin") + + coEvery { fileHandler.checkUrlExists(any()) } returns true + coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile + + val result = retriever.retrieveEsp32Firmware(release, hardware) {} + + assertEquals(expectedFile, result) + coVerify { + fileHandler.checkUrlExists( + "https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/firmware-tlora-v2-2.5.0.bin", + ) + fileHandler.downloadFile( + "https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/firmware-tlora-v2-2.5.0.bin", + "firmware-tlora-v2-2.5.0.bin", + any(), + ) + } + // Verify we DID NOT check for mt-esp32-ota.bin + coVerify(exactly = 0) { fileHandler.checkUrlExists(match { it.contains("mt-esp32-ota.bin") }) } + } + + @Test + fun `retrieveOtaFirmware uses correct zip extension for NRF52`() = runTest { + val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/nrf52.zip") + val hardware = + DeviceHardware( + hwModelSlug = "RAK4631", + platformioTarget = "rak4631", + architecture = "nrf52840", + supportsUnifiedOta = false, // OTA via DFU zip + ) + val expectedFile = File("firmware-rak4631-2.5.0-ota.zip") + + coEvery { fileHandler.checkUrlExists(any()) } returns true + coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile + + val result = retriever.retrieveOtaFirmware(release, hardware) {} + + assertEquals(expectedFile, result) + coVerify { + fileHandler.checkUrlExists( + "https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/firmware-rak4631-2.5.0-ota.zip", + ) + } + } + + @Test + fun `retrieveUsbFirmware uses correct uf2 extension for RP2040`() = runTest { + val release = FirmwareRelease(id = "v2.5.0", zipUrl = "https://example.com/rp2040.zip") + val hardware = DeviceHardware(hwModelSlug = "RPI_PICO", platformioTarget = "pico", architecture = "rp2040") + val expectedFile = File("firmware-pico-2.5.0.uf2") + + coEvery { fileHandler.checkUrlExists(any()) } returns true + coEvery { fileHandler.downloadFile(any(), any(), any()) } returns expectedFile + + val result = retriever.retrieveUsbFirmware(release, hardware) {} + + assertEquals(expectedFile, result) + coVerify { + fileHandler.checkUrlExists( + "https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master/firmware-2.5.0/firmware-pico-2.5.0.uf2", + ) + } + } +} diff --git a/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportTest.kt b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportTest.kt new file mode 100644 index 000000000..7ef65ae60 --- /dev/null +++ b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportTest.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.firmware.ota + +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic +import no.nordicsemi.kotlin.ble.client.RemoteService +import no.nordicsemi.kotlin.ble.client.android.CentralManager +import no.nordicsemi.kotlin.ble.client.android.Peripheral +import no.nordicsemi.kotlin.ble.core.ConnectionState +import org.junit.Test +import java.util.UUID +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.toKotlinUuid + +private val SERVICE_UUID = UUID.fromString("4FAFC201-1FB5-459E-8FCC-C5C9C331914B") +private val OTA_CHARACTERISTIC_UUID = UUID.fromString("62ec0272-3ec5-11eb-b378-0242ac130005") +private val TX_CHARACTERISTIC_UUID = UUID.fromString("62ec0272-3ec5-11eb-b378-0242ac130003") + +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalUuidApi::class) +class BleOtaTransportTest { + + private val centralManager: CentralManager = mockk() + private val address = "00:11:22:33:44:55" + private val transport = BleOtaTransport(centralManager, address) + + @Test + fun `race condition check - response before waitForResponse`() = runTest { + val peripheral: Peripheral = mockk(relaxed = true) + val otaChar: RemoteCharacteristic = mockk(relaxed = true) + val txChar: RemoteCharacteristic = mockk(relaxed = true) + val service: RemoteService = mockk(relaxed = true) + + every { centralManager.getBondedPeripherals() } returns listOf(peripheral) + every { peripheral.address } returns address + every { peripheral.state } returns MutableStateFlow(ConnectionState.Connected) + + coEvery { peripheral.services(any()) } returns MutableStateFlow(listOf(service)) + every { service.uuid } returns SERVICE_UUID.toKotlinUuid() + every { service.characteristics } returns listOf(otaChar, txChar) + every { otaChar.uuid } returns OTA_CHARACTERISTIC_UUID.toKotlinUuid() + every { txChar.uuid } returns TX_CHARACTERISTIC_UUID.toKotlinUuid() + + coEvery { centralManager.connect(any(), any()) } returns Unit + + val notificationFlow = MutableSharedFlow() + every { txChar.subscribe() } returns notificationFlow + + // Connect + transport.connect().getOrThrow() + + // Simulate sending a command and getting a response BEFORE calling startOta + // This is tricky to simulate exactly as in the real race, but we can verify + // if responseFlow is indeed dropping messages. + + // In startOta: + // 1. sendCommand(command) + // 2. waitForResponse() -> responseFlow.first() + + // If the device is super fast, the notification arrives between 1 and 2. + + val size = 100L + val hash = "hash" + + // We mock write to immediately emit to notificationFlow + coEvery { otaChar.write(any(), any()) } coAnswers + { + println("Mock writing, emitting OK to notificationFlow") + notificationFlow.emit("OK\n".toByteArray()) + println("OK emitted to notificationFlow") + } + + println("Calling startOta") + val result = transport.startOta(size, hash) {} + println("startOta result: $result") + + assert(result.isSuccess) + } +} diff --git a/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandlerTest.kt b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandlerTest.kt new file mode 100644 index 000000000..981067a03 --- /dev/null +++ b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandlerTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.firmware.ota + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import no.nordicsemi.kotlin.ble.client.android.CentralManager +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.feature.firmware.FirmwareRetriever +import org.meshtastic.feature.firmware.FirmwareUpdateState +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +class Esp32OtaUpdateHandlerTest { + + private val firmwareRetriever: FirmwareRetriever = mockk() + private val serviceRepository: ServiceRepository = mockk() + private val centralManager: CentralManager = mockk() + private val context: Context = mockk() + private val contentResolver: ContentResolver = mockk() + + private val handler = Esp32OtaUpdateHandler(firmwareRetriever, serviceRepository, centralManager, context) + + @Before + fun setUp() { + mockkStatic("org.jetbrains.compose.resources.StringResourcesKt") + coEvery { org.jetbrains.compose.resources.getString(any()) } returns "Mocked String" + coEvery { org.jetbrains.compose.resources.getString(any(), *anyVararg()) } answers + { + val args = secondArg>() + if (args.isNotEmpty()) { + "OTA update failed: ${args[0]}" + } else { + "Mocked String with args" + } + } + } + + @After + fun tearDown() { + unmockkStatic("org.jetbrains.compose.resources.StringResourcesKt") + } + + @Test + fun `startUpdate from URI propagates exception when reading fails`() = runTest { + val release = FirmwareRelease(id = "local", title = "Local File", zipUrl = "", releaseNotes = "") + val hardware = DeviceHardware(hwModelSlug = "V3", architecture = "esp32") + val target = "00:11:22:33:44:55" + val uri: Uri = mockk() + + every { context.contentResolver } returns contentResolver + every { contentResolver.openInputStream(uri) } throws IOException("Read error") + + val states = mutableListOf() + + handler.startUpdate(release, hardware, target, { states.add(it) }, uri) + + // Before fix, this would be FirmwareUpdateState.Error("Could not retrieve firmware file.") + // After fix, it should ideally contain "Read error" or be the original exception if we don't catch it too + // early. + // Esp32OtaUpdateHandler.performUpdate catches Exception and uses e.message. + + val lastState = states.last() + assert(lastState is FirmwareUpdateState.Error) + assertEquals("OTA update failed: Read error", (lastState as FirmwareUpdateState.Error).error) + } +} diff --git a/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/UnifiedOtaProtocolTest.kt b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/UnifiedOtaProtocolTest.kt new file mode 100644 index 000000000..1f1707071 --- /dev/null +++ b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/UnifiedOtaProtocolTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.firmware.ota + +import org.junit.Assert.assertEquals +import org.junit.Test + +class UnifiedOtaProtocolTest { + + @Test + fun `OtaCommand StartOta produces correct command string`() { + val size = 123456L + val hash = "abc123def456" + val command = OtaCommand.StartOta(size, hash) + + assertEquals("OTA 123456 abc123def456\n", command.toString()) + } + + @Test + fun `OtaCommand StartOta handles large size and long hash`() { + val size = 4294967295L + val hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + val command = OtaCommand.StartOta(size, hash) + + assertEquals( + "OTA 4294967295 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n", + command.toString(), + ) + } + + @Test + fun `OtaResponse parse handles basic success cases`() { + assertEquals(OtaResponse.Ok(), OtaResponse.parse("OK")) + assertEquals(OtaResponse.Ok(), OtaResponse.parse("OK\n")) + assertEquals(OtaResponse.Ack, OtaResponse.parse("ACK")) + assertEquals(OtaResponse.Erasing, OtaResponse.parse("ERASING")) + } + + @Test + fun `OtaResponse parse handles detailed OK with version info`() { + val response = OtaResponse.parse("OK 1.0 2.3.4 42 v2.3.4-abc123\n") + + assert(response is OtaResponse.Ok) + val ok = response as OtaResponse.Ok + assertEquals("1.0", ok.hwVersion) + assertEquals("2.3.4", ok.fwVersion) + assertEquals(42, ok.rebootCount) + assertEquals("v2.3.4-abc123", ok.gitHash) + } + + @Test + fun `OtaResponse parse handles detailed OK with partial data`() { + // Test with fewer than expected parts (should fallback to basic OK) + val response = OtaResponse.parse("OK 1.0 2.3.4\n") + assertEquals(OtaResponse.Ok(), response) + } + + @Test + fun `OtaResponse parse handles error cases`() { + val err1 = OtaResponse.parse("ERR Hash Rejected") + assert(err1 is OtaResponse.Error) + assertEquals("Hash Rejected", (err1 as OtaResponse.Error).message) + + val err2 = OtaResponse.parse("ERR") + assert(err2 is OtaResponse.Error) + assertEquals("Unknown error", (err2 as OtaResponse.Error).message) + } + + @Test + fun `OtaResponse parse handles malformed or unexpected input`() { + val response = OtaResponse.parse("RANDOM_GARBAGE") + assert(response is OtaResponse.Error) + assertEquals("Unknown response: RANDOM_GARBAGE", (response as OtaResponse.Error).message) + } +} diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index 1f2765c7e..58b4397fc 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -136,7 +136,7 @@ fun SettingsScreen( val localConfig by settingsViewModel.localConfig.collectAsStateWithLifecycle() val ourNode by settingsViewModel.ourNodeInfo.collectAsStateWithLifecycle() val isConnected by settingsViewModel.isConnected.collectAsStateWithLifecycle(false) - val isDfuCapable by settingsViewModel.isDfuCapable.collectAsStateWithLifecycle() + val isOtaCapable by settingsViewModel.isOtaCapable.collectAsStateWithLifecycle() val destNode by viewModel.destNode.collectAsState() val state by viewModel.radioConfigState.collectAsStateWithLifecycle() var isWaiting by remember { mutableStateOf(false) } @@ -249,7 +249,7 @@ fun SettingsScreen( isManaged = localConfig.security.isManaged, node = destNode, excludedModulesUnlocked = excludedModulesUnlocked, - isDfuCapable = isDfuCapable, + isOtaCapable = isOtaCapable, onPreserveFavoritesToggle = { viewModel.setPreserveFavorites(it) }, onRouteClick = { route -> isWaiting = true diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt index 308c64380..9866f8c18 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.settings import android.app.Application +import android.icu.text.SimpleDateFormat import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -45,12 +46,14 @@ import org.meshtastic.core.database.DatabaseManager import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.database.model.Node import org.meshtastic.core.datastore.UiPreferencesDataSource +import org.meshtastic.core.model.Capabilities import org.meshtastic.core.model.Position import org.meshtastic.core.model.util.positionToMeter import org.meshtastic.core.prefs.meshlog.MeshLogPrefs import org.meshtastic.core.prefs.radio.RadioPrefs import org.meshtastic.core.prefs.radio.isBle import org.meshtastic.core.prefs.radio.isSerial +import org.meshtastic.core.prefs.radio.isTcp import org.meshtastic.core.prefs.ui.UiPrefs import org.meshtastic.core.service.IMeshService import org.meshtastic.core.service.ServiceRepository @@ -61,7 +64,6 @@ import org.meshtastic.proto.Portnums import java.io.BufferedWriter import java.io.FileNotFoundException import java.io.FileWriter -import java.text.SimpleDateFormat import java.util.Locale import javax.inject.Inject import kotlin.math.roundToInt @@ -118,15 +120,22 @@ constructor( val appVersionName get() = buildConfigProvider.versionName - val isDfuCapable: StateFlow = + val isOtaCapable: StateFlow = combine(ourNodeInfo, serviceRepository.connectionState) { node, connectionState -> Pair(node, connectionState) } .flatMapLatest { (node, connectionState) -> if (node == null || !connectionState.isConnected()) { flowOf(false) - } else if (radioPrefs.isBle() || radioPrefs.isSerial()) { + } else if (radioPrefs.isBle() || radioPrefs.isSerial() || radioPrefs.isTcp()) { val hwModel = node.user.hwModel.number val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel).getOrNull() - flow { emit(hw?.requiresDfu == true) } + // Support both Nordic DFU (requiresDfu) and ESP32 Unified OTA (supportsUnifiedOta) + val capabilities = Capabilities(node.metadata?.firmwareVersion) + + // ESP32 Unified OTA is only supported via BLE or WiFi (TCP), not USB Serial. + val isEsp32OtaSupported = + hw?.supportsUnifiedOta == true && capabilities.supportsEsp32Ota && !radioPrefs.isSerial() + + flow { emit(hw?.requiresDfu == true || isEsp32OtaSupported) } } else { flowOf(false) } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt index 2383d0b6c..5d13a76a4 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-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 @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.settings.radio import androidx.compose.foundation.layout.Arrangement @@ -88,7 +87,7 @@ fun RadioConfigItemList( isManaged: Boolean, node: Node? = null, excludedModulesUnlocked: Boolean = false, - isDfuCapable: Boolean = false, + isOtaCapable: Boolean = false, onPreserveFavoritesToggle: (Boolean) -> Unit = {}, onRouteClick: (Enum<*>) -> Unit = {}, onImport: () -> Unit = {}, @@ -212,7 +211,7 @@ fun RadioConfigItemList( ManagedMessage() } - if (isDfuCapable && state.isLocal) { + if (isOtaCapable && state.isLocal) { ListItem( text = stringResource(Res.string.firmware_update_title), leadingIcon = Icons.Rounded.SystemUpdate, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fb94a9375..02c32d872 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ kotlinx-coroutines-android = "1.10.2" kotlinx-serialization = "1.9.0" ktlint = "1.7.1" kover = "0.9.4" +mockk = "1.13.17" # Compose Multiplatform compose-multiplatform = "1.10.0" @@ -137,6 +138,7 @@ okhttp3-logging-interceptor = { module = "com.squareup.okhttp3:logging-intercept androidx-test-ext-junit = { module = "androidx.test.ext:junit", version = "1.3.0" } androidx-test-runner = { module = "androidx.test:runner", version = "1.7.0" } junit = { module = "junit:junit", version = "4.13.2" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } # Other aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlibraries" }