feat: Add ESP32 Unified OTA update support (#4095)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
This commit is contained in:
James Rich
2026-01-14 21:22:30 -06:00
committed by GitHub
parent 6b5dd24249
commit 2a60480bd9
40 changed files with 3410 additions and 717 deletions

View File

@@ -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
}
]
}
}

View File

@@ -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
}
}
}

View File

@@ -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 }
}

View File

@@ -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)
}
}
}

View File

@@ -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) {}

View File

@@ -179,6 +179,7 @@ constructor(
}
base.copy(
requiresBootloaderUpgradeForOta = quirk.requiresBootloaderUpgradeForOta,
supportsUnifiedOta = quirk.supportsUnifiedOta,
bootloaderInfoUrl = quirk.infoUrl,
)
} else {

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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,
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)

View File

@@ -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")
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String>? = null,
)

View File

@@ -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);
}

View File

@@ -562,7 +562,7 @@
<string name="triple_click_adhoc_ping">Triple Click Ad Hoc Ping</string>
<string name="time_zone">Time Zone</string>
<string name="led_heartbeat">LED Heartbeat</string>
<string name="display_config">Display</string>
<string name="display_config">Device Display</string>
<string name="screen_on_for">Screen on for</string>
<string name="carousel_interval">Carousel interval</string>
<string name="compass_north_top">Compass north top</string>
@@ -1022,17 +1022,20 @@
<string name="firmware_update_checking">Checking for updates...</string>
<string name="firmware_update_device">Device: %1$s</string>
<string name="firmware_update_currently_installed">Currently Installed: %1$s</string>
<string name="firmware_update_latest">Latest Release: %1$s</string>
<string name="firmware_update_latest">Update To: %1$s</string>
<string name="firmware_update_stable">Stable</string>
<string name="firmware_update_alpha">Alpha</string>
<string name="firmware_update_disconnect_warning">Note: This will temporarily disconnect your device during the update.</string>
<string name="firmware_update_downloading">Downloading firmware... %1$d%</string>
<string name="firmware_update_downloading_percent">Downloading firmware... %1$d%%</string>
<string name="firmware_update_error">Error: %1$s</string>
<string name="firmware_update_retry">Retry</string>
<string name="firmware_update_success">Update Successful!</string>
<string name="firmware_update_done">Done</string>
<string name="firmware_update_starting_dfu">Starting DFU...</string>
<string name="firmware_update_updating">Updating... %1$s%</string>
<string name="firmware_update_updating">Updating... %1$s</string>
<string name="firmware_update_enabling_dfu">Enabling DFU mode...</string>
<string name="firmware_update_validating">Validating firmware...</string>
<string name="firmware_update_disconnecting">Disconnecting...</string>
<string name="firmware_update_unknown_hardware">Unknown hardware model: %1$d</string>
<string name="firmware_update_invalid_address">Connected device is not a valid BLE device or address is unknown (%1$s).</string>
<string name="firmware_update_no_device">No device connected</string>
@@ -1046,6 +1049,8 @@
<string name="firmware_update_almost_there">Almost there...</string>
<string name="firmware_update_taking_a_while">This might take a minute...</string>
<string name="firmware_update_select_file">Select Local File</string>
<string name="firmware_update_local_file">Local File</string>
<string name="firmware_update_source_local">Source: Local File</string>
<string name="firmware_update_unknown_release">Unknown remote release</string>
<string name="firmware_update_disclaimer_title">Update Warning</string>
<string name="firmware_update_disclaimer_text">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.</string>
@@ -1058,9 +1063,39 @@
<string name="firmware_update_flashing">Flashing device, please wait...</string>
<string name="firmware_update_method_usb">USB File Transfer</string>
<string name="firmware_update_method_ble">BLE OTA</string>
<string name="firmware_update_method_wifi">WiFi OTA</string>
<string name="firmware_update_method_detail">Update via %1$s</string>
<string name="firmware_update_usb_instruction_title">Select DFU USB Drive</string>
<string name="firmware_update_usb_instruction_text">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.</string>
<string name="firmware_update_verifying">Verifying update...</string>
<string name="firmware_update_verification_failed">Verification timed out. Device did not reconnect in time.</string>
<string name="firmware_update_waiting_reconnect">Waiting for device to reconnect...</string>
<string name="firmware_update_target">Target: %1$s</string>
<string name="firmware_update_release_notes">Release Notes</string>
<string name="firmware_update_unknown_error">Unknown error</string>
<string name="firmware_update_local_failed">Local update failed</string>
<string name="firmware_update_dfu_error">DFU Error: %1$s</string>
<string name="firmware_update_dfu_aborted">DFU Aborted</string>
<string name="firmware_update_node_info_missing">Node user information is missing.</string>
<string name="firmware_update_battery_low">Battery too low (%1$d%%). Please charge your device before updating.</string>
<string name="firmware_update_retrieval_failed">Could not retrieve firmware file.</string>
<string name="firmware_update_nordic_failed">Nordic DFU Update failed</string>
<string name="firmware_update_usb_failed">USB Update failed</string>
<string name="firmware_update_hash_rejected">Firmware hash rejected. Device may require hash provisioning or bootloader update.</string>
<string name="firmware_update_ota_failed">OTA update failed: %1$s</string>
<string name="firmware_update_loading">Loading firmware...</string>
<string name="firmware_update_waiting_reboot">Waiting for device to reboot into OTA mode...</string>
<string name="firmware_update_connecting_attempt">Connecting to device (attempt %1$d/%2$d)...</string>
<string name="firmware_update_checking_version">Checking device version...</string>
<string name="firmware_update_starting_ota">Starting OTA update...</string>
<string name="firmware_update_uploading">Uploading firmware...</string>
<string name="firmware_update_uploading_progress">Uploading firmware... %1$d%% (%2$s)</string>
<string name="firmware_update_rebooting_device">Rebooting device...</string>
<string name="firmware_update_channel_name">Firmware Update</string>
<string name="firmware_update_channel_description">Firmware update status</string>
<string name="firmware_update_erasing">Erasing...</string>
<string name="back">Back</string>
<string name="interval_unset">Unset</string>
<string name="interval_always_on">Always On</string>
<plurals name="plurals_seconds">

View File

@@ -109,3 +109,86 @@ classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
</details>
<!--endregion-->
## 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).

View File

@@ -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<LibraryExtension> { 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)
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Pair<ZipEntry, File>>()
val targetLowerCase = target.lowercase()
val preferredFilenameLower = preferredFilename?.lowercase()
val matchingEntries = mutableListOf<Pair<ZipEntry, File>>()
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<Pair<ZipEntry, File>>()
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<Pair<ZipEntry, File>>()
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)}[\\-_\\.].*")

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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?
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<DfuInternalState> = 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
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
@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))
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<FirmwareReleaseType> = _selectedReleaseType.asStateFlow()
private val _selectedRelease = MutableStateFlow<FirmwareRelease?>(null)
val selectedRelease: StateFlow<FirmwareRelease?> = _selectedRelease.asStateFlow()
private val _deviceHardware = MutableStateFlow<DeviceHardware?>(null)
val deviceHardware = _deviceHardware.asStateFlow()
private val _currentFirmwareVersion = MutableStateFlow<String?>(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<Unit> = 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<FirmwareRelease?> = when (type) {
FirmwareReleaseType.STABLE -> stableRelease
FirmwareReleaseType.ALPHA -> alphaRelease
}
private fun dfuProgressFlow(context: Context): Flow<DfuInternalState> = 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)
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<DfuInternalState> = 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
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Unit> = 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" } }
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String>(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<String>()
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<Unit> = 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<Unit> = 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<Unit> = 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
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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) }
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Unit>
/**
* 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<Unit>
/**
* 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<Unit>
/** 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)
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Unit> = 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<Unit> = 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<Unit> = 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<String> =
withContext(Dispatchers.IO) {
val devices = mutableListOf<String>()
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
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<File>(), 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",
)
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<ByteArray>()
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)
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Array<Any?>>()
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<FirmwareUpdateState>()
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)
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View File

@@ -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

View File

@@ -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<Boolean> =
val isOtaCapable: StateFlow<Boolean> =
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)
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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,

View File

@@ -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" }