mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor: migrate from Hilt to Koin and expand KMP common modules (#4746)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
a5390a80e7
commit
875cf1cff2
440 changed files with 3738 additions and 3508 deletions
15
feature/firmware/src/androidMain/AndroidManifest.xml
Normal file
15
feature/firmware/src/androidMain/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
|
||||
<application>
|
||||
<service android:name=".FirmwareDfuService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice">
|
||||
<intent-filter>
|
||||
<action android:name="no.nordicsemi.android.dfu.broadcast.BROADCAST_ACTION" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
/*
|
||||
* 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 co.touchlab.kermit.Logger
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.head
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.contentLength
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.utils.io.jvm.javaio.toInputStream
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.common.util.toPlatformUri
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
private const val DOWNLOAD_BUFFER_SIZE = 8192
|
||||
|
||||
/**
|
||||
* Helper class to handle file operations related to firmware updates, such as downloading, copying from URI, and
|
||||
* extracting specific files from Zip archives.
|
||||
*/
|
||||
@Single
|
||||
class AndroidFirmwareFileHandler(private val context: Context, private val client: HttpClient) : FirmwareFileHandler {
|
||||
private val tempDir = File(context.cacheDir, "firmware_update")
|
||||
|
||||
override fun cleanupAllTemporaryFiles() {
|
||||
runCatching {
|
||||
if (tempDir.exists()) {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
tempDir.mkdirs()
|
||||
}
|
||||
.onFailure { e -> Logger.w(e) { "Failed to cleanup temp directory" } }
|
||||
}
|
||||
|
||||
override suspend fun checkUrlExists(url: String): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
client.head(url).status.isSuccess()
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.w(e) { "Failed to check URL existence: $url" }
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun downloadFile(url: String, fileName: String, onProgress: (Float) -> Unit): String? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val response =
|
||||
try {
|
||||
client.get(url)
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.w(e) { "Download failed for $url" }
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
if (!response.status.isSuccess()) {
|
||||
Logger.w { "Download failed: ${response.status.value} for $url" }
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
val body = response.bodyAsChannel()
|
||||
val contentLength = response.contentLength() ?: -1L
|
||||
|
||||
if (!tempDir.exists()) tempDir.mkdirs()
|
||||
|
||||
val targetFile = java.io.File(tempDir, fileName)
|
||||
|
||||
body.toInputStream().use { input ->
|
||||
java.io.FileOutputStream(targetFile).use { output ->
|
||||
val buffer = ByteArray(DOWNLOAD_BUFFER_SIZE)
|
||||
var bytesRead: Int
|
||||
var totalBytesRead = 0L
|
||||
|
||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||
if (!isActive) throw CancellationException("Download cancelled")
|
||||
|
||||
output.write(buffer, 0, bytesRead)
|
||||
totalBytesRead += bytesRead
|
||||
|
||||
if (contentLength > 0) {
|
||||
onProgress(totalBytesRead.toFloat() / contentLength)
|
||||
}
|
||||
}
|
||||
if (contentLength != -1L && totalBytesRead != contentLength) {
|
||||
throw IOException("Incomplete download: expected $contentLength bytes, got $totalBytesRead")
|
||||
}
|
||||
}
|
||||
}
|
||||
targetFile.absolutePath
|
||||
}
|
||||
|
||||
override suspend fun extractFirmwareFromZip(
|
||||
zipFilePath: String,
|
||||
hardware: DeviceHardware,
|
||||
fileExtension: String,
|
||||
preferredFilename: String?,
|
||||
): String? = withContext(Dispatchers.IO) {
|
||||
val zipFile = java.io.File(zipFilePath)
|
||||
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()
|
||||
|
||||
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.absolutePath
|
||||
}
|
||||
}
|
||||
entry = zipInput.nextEntry
|
||||
}
|
||||
}
|
||||
matchingEntries.minByOrNull { it.first.name.length }?.second?.absolutePath
|
||||
}
|
||||
|
||||
override suspend fun extractFirmware(
|
||||
uri: CommonUri,
|
||||
hardware: DeviceHardware,
|
||||
fileExtension: String,
|
||||
preferredFilename: String?,
|
||||
): String? = 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 platformUri = uri.toPlatformUri() as android.net.Uri
|
||||
val inputStream = context.contentResolver.openInputStream(platformUri) ?: return@withContext null
|
||||
ZipInputStream(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.absolutePath
|
||||
}
|
||||
}
|
||||
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?.absolutePath
|
||||
}
|
||||
|
||||
override suspend fun getFileSize(path: String): Long = withContext(Dispatchers.IO) {
|
||||
val file = File(path)
|
||||
if (file.exists()) file.length() else 0L
|
||||
}
|
||||
|
||||
override suspend fun deleteFile(path: String) = withContext(Dispatchers.IO) {
|
||||
val file = File(path)
|
||||
if (file.exists()) file.delete()
|
||||
}
|
||||
|
||||
private fun isValidFirmwareFile(filename: String, target: String, fileExtension: String): Boolean {
|
||||
val regex = Regex(".*[\\-_]${Regex.escape(target)}[\\-_\\.].*")
|
||||
return filename.endsWith(fileExtension) &&
|
||||
filename.contains(target) &&
|
||||
(regex.matches(filename) || filename.startsWith("$target-") || filename.startsWith("$target."))
|
||||
}
|
||||
|
||||
override suspend fun copyFileToUri(sourcePath: String, destinationUri: CommonUri): Long =
|
||||
withContext(Dispatchers.IO) {
|
||||
val inputStream = java.io.FileInputStream(java.io.File(sourcePath))
|
||||
val outputStream =
|
||||
context.contentResolver.openOutputStream(destinationUri.toPlatformUri() as android.net.Uri)
|
||||
?: throw IOException("Cannot open content URI for writing")
|
||||
|
||||
inputStream.use { input -> outputStream.use { output -> input.copyTo(output) } }
|
||||
}
|
||||
|
||||
override suspend fun copyUriToUri(sourceUri: CommonUri, destinationUri: CommonUri): Long =
|
||||
withContext(Dispatchers.IO) {
|
||||
val inputStream =
|
||||
context.contentResolver.openInputStream(sourceUri.toPlatformUri() as android.net.Uri)
|
||||
?: throw IOException("Cannot open source URI")
|
||||
val outputStream =
|
||||
context.contentResolver.openOutputStream(destinationUri.toPlatformUri() as android.net.Uri)
|
||||
?: throw IOException("Cannot open destination URI")
|
||||
|
||||
inputStream.use { input -> outputStream.use { output -> input.copyTo(output) } }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* 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 kotlinx.coroutines.flow.Flow
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.repository.RadioPrefs
|
||||
import org.meshtastic.core.repository.isBle
|
||||
import org.meshtastic.core.repository.isSerial
|
||||
import org.meshtastic.core.repository.isTcp
|
||||
import org.meshtastic.feature.firmware.ota.Esp32OtaUpdateHandler
|
||||
|
||||
/** Orchestrates the firmware update process by choosing the correct handler. */
|
||||
@Single
|
||||
class AndroidFirmwareUpdateManager(
|
||||
private val radioPrefs: RadioPrefs,
|
||||
private val nordicDfuHandler: NordicDfuHandler,
|
||||
private val usbUpdateHandler: UsbUpdateHandler,
|
||||
private val esp32OtaUpdateHandler: Esp32OtaUpdateHandler,
|
||||
) : FirmwareUpdateManager {
|
||||
|
||||
/** Start the update process based on the current connection and hardware. */
|
||||
override suspend fun startUpdate(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
address: String,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
firmwareUri: CommonUri?,
|
||||
): String? {
|
||||
val handler = getHandler(hardware)
|
||||
val target = getTarget(address)
|
||||
|
||||
return handler.startUpdate(
|
||||
release = release,
|
||||
hardware = hardware,
|
||||
target = target,
|
||||
updateState = updateState,
|
||||
firmwareUri = firmwareUri,
|
||||
)
|
||||
}
|
||||
|
||||
override fun dfuProgressFlow(): Flow<DfuInternalState> = nordicDfuHandler.progressFlow()
|
||||
|
||||
private fun getHandler(hardware: DeviceHardware): FirmwareUpdateHandler = when {
|
||||
radioPrefs.isSerial() -> {
|
||||
if (isEsp32Architecture(hardware.architecture)) {
|
||||
error("Serial/USB firmware update not supported for ESP32 devices from the app")
|
||||
}
|
||||
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.value) ?: ""
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* 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 kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
/** Manages USB-related interactions for firmware updates. */
|
||||
@Single
|
||||
class AndroidFirmwareUsbManager(private val context: Context) : FirmwareUsbManager {
|
||||
/** Observe when a USB device is detached. */
|
||||
override 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" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* 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.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.resources.Res
|
||||
import org.meshtastic.core.resources.firmware_update_channel_description
|
||||
import org.meshtastic.core.resources.firmware_update_channel_name
|
||||
import org.meshtastic.core.model.util.isDebug as isDebugFlag
|
||||
|
||||
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, channelName, NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = channelDesc
|
||||
setShowBadge(false)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
super.onCreate()
|
||||
}
|
||||
|
||||
override fun getNotificationTarget(): Class<out Activity>? = try {
|
||||
// Best effort to find the main activity dynamically
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||
val className = launchIntent?.component?.className ?: "org.meshtastic.app.MainActivity"
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
Class.forName(className) as Class<out Activity>
|
||||
} catch (_: Exception) {
|
||||
Activity::class.java
|
||||
}
|
||||
|
||||
override fun isDebug(): Boolean = isDebugFlag
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
/*
|
||||
* 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.koin.core.annotation.Single
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
|
||||
/** Retrieves firmware files, either by direct download or by extracting from a release asset. */
|
||||
@Single
|
||||
class FirmwareRetriever(private val fileHandler: FirmwareFileHandler) {
|
||||
suspend fun retrieveOtaFirmware(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
onProgress: (Float) -> Unit,
|
||||
): String? = retrieve(
|
||||
release = release,
|
||||
hardware = hardware,
|
||||
onProgress = onProgress,
|
||||
fileSuffix = "-ota.zip",
|
||||
internalFileExtension = ".zip",
|
||||
)
|
||||
|
||||
suspend fun retrieveUsbFirmware(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
onProgress: (Float) -> Unit,
|
||||
): String? = retrieve(
|
||||
release = release,
|
||||
hardware = hardware,
|
||||
onProgress = onProgress,
|
||||
fileSuffix = ".uf2",
|
||||
internalFileExtension = ".uf2",
|
||||
)
|
||||
|
||||
suspend fun retrieveEsp32Firmware(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
onProgress: (Float) -> Unit,
|
||||
): String? {
|
||||
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
|
||||
}
|
||||
|
||||
// Fallback to board-specific binary using the now-accurate platformioTarget.
|
||||
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,
|
||||
): String? {
|
||||
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.extractFirmwareFromZip(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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,894 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
@file:Suppress("TooManyFunctions")
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.meshtastic.feature.firmware
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.text.TextAutoSize
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearWavyProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.database.entity.FirmwareReleaseType
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.back
|
||||
import org.meshtastic.core.resources.cancel
|
||||
import org.meshtastic.core.resources.chirpy
|
||||
import org.meshtastic.core.resources.dont_show_again_for_device
|
||||
import org.meshtastic.core.resources.firmware_update_almost_there
|
||||
import org.meshtastic.core.resources.firmware_update_alpha
|
||||
import org.meshtastic.core.resources.firmware_update_checking
|
||||
import org.meshtastic.core.resources.firmware_update_currently_installed
|
||||
import org.meshtastic.core.resources.firmware_update_device
|
||||
import org.meshtastic.core.resources.firmware_update_disclaimer_chirpy_says
|
||||
import org.meshtastic.core.resources.firmware_update_disclaimer_text
|
||||
import org.meshtastic.core.resources.firmware_update_disclaimer_title
|
||||
import org.meshtastic.core.resources.firmware_update_disconnect_warning
|
||||
import org.meshtastic.core.resources.firmware_update_do_not_close
|
||||
import org.meshtastic.core.resources.firmware_update_done
|
||||
import org.meshtastic.core.resources.firmware_update_error
|
||||
import org.meshtastic.core.resources.firmware_update_hang_tight
|
||||
import org.meshtastic.core.resources.firmware_update_keep_device_close
|
||||
import org.meshtastic.core.resources.firmware_update_latest
|
||||
import org.meshtastic.core.resources.firmware_update_local_file
|
||||
import org.meshtastic.core.resources.firmware_update_method_detail
|
||||
import org.meshtastic.core.resources.firmware_update_rak4631_bootloader_hint
|
||||
import org.meshtastic.core.resources.firmware_update_release_notes
|
||||
import org.meshtastic.core.resources.firmware_update_retry
|
||||
import org.meshtastic.core.resources.firmware_update_save_dfu_file
|
||||
import org.meshtastic.core.resources.firmware_update_select_file
|
||||
import org.meshtastic.core.resources.firmware_update_source_local
|
||||
import org.meshtastic.core.resources.firmware_update_stable
|
||||
import org.meshtastic.core.resources.firmware_update_success
|
||||
import org.meshtastic.core.resources.firmware_update_taking_a_while
|
||||
import org.meshtastic.core.resources.firmware_update_target
|
||||
import org.meshtastic.core.resources.firmware_update_title
|
||||
import org.meshtastic.core.resources.firmware_update_unknown_release
|
||||
import org.meshtastic.core.resources.firmware_update_usb_bootloader_warning
|
||||
import org.meshtastic.core.resources.firmware_update_usb_instruction_text
|
||||
import org.meshtastic.core.resources.firmware_update_usb_instruction_title
|
||||
import org.meshtastic.core.resources.firmware_update_verification_failed
|
||||
import org.meshtastic.core.resources.firmware_update_verifying
|
||||
import org.meshtastic.core.resources.firmware_update_waiting_reconnect
|
||||
import org.meshtastic.core.resources.i_know_what_i_m_doing
|
||||
import org.meshtastic.core.resources.img_chirpy
|
||||
import org.meshtastic.core.resources.learn_more
|
||||
import org.meshtastic.core.resources.okay
|
||||
import org.meshtastic.core.resources.save
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.core.ui.icon.Bluetooth
|
||||
import org.meshtastic.core.ui.icon.CheckCircle
|
||||
import org.meshtastic.core.ui.icon.CloudDownload
|
||||
import org.meshtastic.core.ui.icon.Dangerous
|
||||
import org.meshtastic.core.ui.icon.Folder
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.Refresh
|
||||
import org.meshtastic.core.ui.icon.SystemUpdate
|
||||
import org.meshtastic.core.ui.icon.Usb
|
||||
import org.meshtastic.core.ui.icon.Warning
|
||||
import org.meshtastic.core.ui.icon.Wifi
|
||||
|
||||
private const val CYCLE_DELAY_MS = 4500L
|
||||
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateViewModel, modifier: Modifier = Modifier) {
|
||||
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()
|
||||
|
||||
var showExitConfirmation by remember { mutableStateOf(false) }
|
||||
val filePickerLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||
uri?.let { viewModel.startUpdateFromFile(CommonUri(it)) }
|
||||
}
|
||||
|
||||
val createDocumentLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/octet-stream"),
|
||||
) { uri: Uri? ->
|
||||
uri?.let { viewModel.saveDfuFile(CommonUri(it)) }
|
||||
}
|
||||
val actions =
|
||||
remember(viewModel, onNavigateUp, 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
|
||||
) {
|
||||
filePickerLauncher.launch("*/*")
|
||||
} else if (readyState.updateMethod is FirmwareUpdateMethod.Usb) {
|
||||
filePickerLauncher.launch("*/*")
|
||||
}
|
||||
}
|
||||
},
|
||||
onSaveFile = { fileName -> createDocumentLauncher.launch(fileName) },
|
||||
onRetry = viewModel::checkForUpdates,
|
||||
onCancel = { showExitConfirmation = true },
|
||||
onDone = { onNavigateUp() },
|
||||
onDismissBootloaderWarning = viewModel::dismissBootloaderWarningForCurrentDevice,
|
||||
)
|
||||
}
|
||||
|
||||
KeepScreenOn(shouldKeepFirmwareScreenOn(state))
|
||||
|
||||
androidx.activity.compose.BackHandler(enabled = shouldKeepFirmwareScreenOn(state)) { showExitConfirmation = true }
|
||||
|
||||
if (showExitConfirmation) {
|
||||
MeshtasticDialog(
|
||||
onDismiss = { showExitConfirmation = false },
|
||||
title = stringResource(Res.string.firmware_update_disclaimer_title),
|
||||
message = stringResource(Res.string.firmware_update_disconnect_warning),
|
||||
confirmText = stringResource(Res.string.firmware_update_retry),
|
||||
onConfirm = {
|
||||
showExitConfirmation = false
|
||||
viewModel.cancelUpdate()
|
||||
onNavigateUp()
|
||||
},
|
||||
dismissText = stringResource(Res.string.back),
|
||||
)
|
||||
}
|
||||
|
||||
FirmwareUpdateScaffold(
|
||||
modifier = modifier,
|
||||
onNavigateUp = onNavigateUp,
|
||||
state = state,
|
||||
selectedReleaseType = selectedReleaseType,
|
||||
actions = actions,
|
||||
deviceHardware = deviceHardware,
|
||||
currentVersion = currentVersion,
|
||||
selectedRelease = selectedRelease,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FirmwareUpdateScaffold(
|
||||
onNavigateUp: () -> Unit,
|
||||
state: FirmwareUpdateState,
|
||||
selectedReleaseType: FirmwareReleaseType,
|
||||
actions: FirmwareUpdateActions,
|
||||
deviceHardware: DeviceHardware?,
|
||||
currentVersion: String?,
|
||||
selectedRelease: FirmwareRelease?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text(stringResource(Res.string.firmware_update_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onNavigateUp() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldKeepFirmwareScreenOn(state: FirmwareUpdateState): Boolean = when (state) {
|
||||
is FirmwareUpdateState.Downloading,
|
||||
is FirmwareUpdateState.Processing,
|
||||
is FirmwareUpdateState.Updating,
|
||||
is FirmwareUpdateState.Verifying,
|
||||
-> true
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FirmwareUpdateContent(
|
||||
state: FirmwareUpdateState,
|
||||
selectedReleaseType: FirmwareReleaseType,
|
||||
actions: FirmwareUpdateActions,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
content = {
|
||||
when (state) {
|
||||
is FirmwareUpdateState.Idle,
|
||||
FirmwareUpdateState.Checking,
|
||||
-> CheckingState()
|
||||
|
||||
is FirmwareUpdateState.Ready ->
|
||||
ReadyState(state = state, selectedReleaseType = selectedReleaseType, actions = actions)
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
private fun ReadyState(
|
||||
state: FirmwareUpdateState.Ready,
|
||||
selectedReleaseType: FirmwareReleaseType,
|
||||
actions: FirmwareUpdateActions,
|
||||
) {
|
||||
var showDisclaimer by remember { mutableStateOf(false) }
|
||||
val device = state.deviceHardware
|
||||
val haptic = LocalHapticFeedback.current
|
||||
|
||||
if (showDisclaimer) {
|
||||
DisclaimerDialog(
|
||||
updateMethod = state.updateMethod,
|
||||
onDismiss = { showDisclaimer = false },
|
||||
onConfirm = {
|
||||
showDisclaimer = false
|
||||
if (selectedReleaseType == FirmwareReleaseType.LOCAL) {
|
||||
actions.onPickFile()
|
||||
} else {
|
||||
actions.onStartUpdate()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (state.showBootloaderWarning) {
|
||||
BootloaderWarningCard(deviceHardware = device, onDismissForDevice = actions.onDismissBootloaderWarning)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (selectedReleaseType == FirmwareReleaseType.LOCAL) {
|
||||
Button(
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
showDisclaimer = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Icon(MeshtasticIcons.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),
|
||||
) {
|
||||
Icon(
|
||||
imageVector =
|
||||
when (state.updateMethod) {
|
||||
FirmwareUpdateMethod.Ble -> MeshtasticIcons.Bluetooth
|
||||
FirmwareUpdateMethod.Usb -> MeshtasticIcons.Usb
|
||||
FirmwareUpdateMethod.Wifi -> MeshtasticIcons.Wifi
|
||||
else -> MeshtasticIcons.SystemUpdate
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
stringResource(
|
||||
resource = Res.string.firmware_update_method_detail,
|
||||
stringResource(state.updateMethod.description),
|
||||
),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
ReleaseNotesCard(state.release.releaseNotes)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisclaimerDialog(updateMethod: FirmwareUpdateMethod, onDismiss: () -> Unit, onConfirm: () -> Unit) {
|
||||
MeshtasticDialog(
|
||||
onDismiss = onDismiss,
|
||||
title = stringResource(Res.string.firmware_update_disclaimer_title),
|
||||
confirmText = stringResource(Res.string.i_know_what_i_m_doing),
|
||||
onConfirm = onConfirm,
|
||||
dismissText = stringResource(Res.string.cancel),
|
||||
text = {
|
||||
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) {
|
||||
Icon(
|
||||
MeshtasticIcons.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
stringResource(Res.string.firmware_update_disconnect_warning),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
if (updateMethod is FirmwareUpdateMethod.Ble) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
ChirpyCard()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChirpyCard() {
|
||||
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,
|
||||
horizontalArrangement = spacedBy(4.dp),
|
||||
) {
|
||||
BasicText(text = "🪜", modifier = Modifier.size(48.dp), autoSize = TextAutoSize.StepBased())
|
||||
AsyncImage(
|
||||
model =
|
||||
ImageRequest.Builder(LocalContext.current)
|
||||
.data(Res.drawable.img_chirpy)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentScale = ContentScale.Fit,
|
||||
contentDescription = stringResource(Res.string.chirpy),
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(Res.string.firmware_update_disclaimer_chirpy_says),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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).crossfade(true).build(),
|
||||
contentScale = ContentScale.Fit,
|
||||
contentDescription = deviceHardware.displayName,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReleaseNotesCard(releaseNotes: String) {
|
||||
ElevatedCard(
|
||||
modifier = Modifier.fillMaxWidth().animateContentSize(),
|
||||
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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().animateContentSize(),
|
||||
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
DeviceHardwareImage(deviceHardware, Modifier.size(80.dp))
|
||||
|
||||
Text(
|
||||
stringResource(Res.string.firmware_update_device, deviceHardware.displayName),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
stringResource(Res.string.firmware_update_target, target),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val currentVersionString =
|
||||
stringResource(
|
||||
Res.string.firmware_update_currently_installed,
|
||||
currentFirmwareVersion?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(Res.string.firmware_update_unknown_release),
|
||||
)
|
||||
Text(modifier = Modifier.fillMaxWidth(), text = currentVersionString)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
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 = "$label$version",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BootloaderWarningCard(deviceHardware: DeviceHardware, onDismissForDevice: () -> Unit) {
|
||||
ElevatedCard(
|
||||
modifier = Modifier.fillMaxWidth().animateContentSize(),
|
||||
colors =
|
||||
CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text =
|
||||
stringResource(Res.string.firmware_update_usb_bootloader_warning, deviceHardware.displayName),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
val slug = deviceHardware.hwModelSlug
|
||||
if (slug.equals("RAK4631", ignoreCase = true)) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.firmware_update_rak4631_bootloader_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
val infoUrl = deviceHardware.bootloaderInfoUrl
|
||||
if (!infoUrl.isNullOrEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val context = LocalContext.current
|
||||
TextButton(
|
||||
onClick = {
|
||||
runCatching {
|
||||
val intent =
|
||||
android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
|
||||
data = infoUrl.toUri()
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(Res.string.learn_more))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
TextButton(onClick = onDismissForDevice) {
|
||||
Text(text = stringResource(Res.string.dont_show_again_for_device))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReleaseTypeSelector(
|
||||
selectedReleaseType: FirmwareReleaseType,
|
||||
onReleaseTypeSelect: (FirmwareReleaseType) -> Unit,
|
||||
) {
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
SegmentedButton(
|
||||
selected = selectedReleaseType == FirmwareReleaseType.STABLE,
|
||||
onClick = { onReleaseTypeSelect(FirmwareReleaseType.STABLE) },
|
||||
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 = 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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(
|
||||
MeshtasticIcons.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) {
|
||||
MeshtasticDialog(
|
||||
onDismiss = { /* No-op to force user to acknowledge */ },
|
||||
title = stringResource(Res.string.firmware_update_usb_instruction_title),
|
||||
confirmText = stringResource(Res.string.okay),
|
||||
onConfirm = {
|
||||
showDialog = false
|
||||
onSaveFile(state.fileName)
|
||||
},
|
||||
text = { Text(stringResource(Res.string.firmware_update_usb_instruction_text)) },
|
||||
dismissable = false,
|
||||
)
|
||||
}
|
||||
|
||||
CircularWavyProgressIndicator(modifier = Modifier.size(64.dp))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
stringResource(Res.string.firmware_update_save_dfu_file),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
if (!showDialog) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(onClick = { onSaveFile(state.fileName) }) { Text(stringResource(Res.string.save)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CyclingMessages() {
|
||||
val messages =
|
||||
listOf(
|
||||
stringResource(Res.string.firmware_update_hang_tight),
|
||||
stringResource(Res.string.firmware_update_keep_device_close),
|
||||
stringResource(Res.string.firmware_update_do_not_close),
|
||||
stringResource(Res.string.firmware_update_almost_there),
|
||||
stringResource(Res.string.firmware_update_taking_a_while),
|
||||
)
|
||||
var currentMessageIndex by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(CYCLE_DELAY_MS)
|
||||
currentMessageIndex = (currentMessageIndex + 1) % messages.size
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
MeshtasticIcons.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 = spacedBy(16.dp)) {
|
||||
OutlinedButton(onClick = onRetry) {
|
||||
Icon(MeshtasticIcons.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 ErrorState(error: String, onRetry: () -> Unit) {
|
||||
Icon(
|
||||
MeshtasticIcons.Dangerous,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
stringResource(Res.string.firmware_update_error, error),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
OutlinedButton(onClick = onRetry) {
|
||||
Icon(MeshtasticIcons.Refresh, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(Res.string.firmware_update_retry))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuccessState(onDone: () -> Unit) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
LaunchedEffect(Unit) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) }
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
MeshtasticIcons.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeepScreenOn(enabled: Boolean) {
|
||||
val view = LocalView.current
|
||||
DisposableEffect(enabled) {
|
||||
if (enabled) {
|
||||
view.keepScreenOn = true
|
||||
}
|
||||
onDispose {
|
||||
if (enabled) {
|
||||
view.keepScreenOn = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
/*
|
||||
* 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 co.touchlab.kermit.Logger
|
||||
import co.touchlab.kermit.Severity
|
||||
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.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.common.util.toPlatformUri
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.firmware_update_downloading_percent
|
||||
import org.meshtastic.core.resources.firmware_update_nordic_failed
|
||||
import org.meshtastic.core.resources.firmware_update_not_found_in_release
|
||||
import org.meshtastic.core.resources.firmware_update_starting_service
|
||||
|
||||
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. */
|
||||
@Single
|
||||
class NordicDfuHandler(
|
||||
private val firmwareRetriever: FirmwareRetriever,
|
||||
private val context: Context,
|
||||
private val radioController: RadioController,
|
||||
) : FirmwareUpdateHandler {
|
||||
|
||||
override suspend fun startUpdate(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
target: String, // Bluetooth address
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
firmwareUri: CommonUri?,
|
||||
): String? =
|
||||
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, CommonUri.parse("file://$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: CommonUri,
|
||||
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)
|
||||
radioController.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.toPlatformUri() as android.net.Uri)
|
||||
.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" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* 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 kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.firmware_update_downloading_percent
|
||||
import org.meshtastic.core.resources.firmware_update_rebooting
|
||||
import org.meshtastic.core.resources.firmware_update_retrieval_failed
|
||||
import org.meshtastic.core.resources.firmware_update_usb_failed
|
||||
|
||||
private const val REBOOT_DELAY = 5000L
|
||||
private const val PERCENT_MAX = 100
|
||||
|
||||
/** Handles firmware updates via USB Mass Storage (UF2). */
|
||||
@Single
|
||||
class UsbUpdateHandler(
|
||||
private val firmwareRetriever: FirmwareRetriever,
|
||||
private val radioController: RadioController,
|
||||
private val nodeRepository: NodeRepository,
|
||||
) : FirmwareUpdateHandler {
|
||||
|
||||
override suspend fun startUpdate(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
target: String, // Unused for USB
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
firmwareUri: CommonUri?,
|
||||
): String? =
|
||||
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)))
|
||||
val myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum ?: 0
|
||||
radioController.rebootToDfu(myNodeNum)
|
||||
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)))
|
||||
val myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum ?: 0
|
||||
radioController.rebootToDfu(myNodeNum)
|
||||
delay(REBOOT_DELAY)
|
||||
|
||||
updateState(FirmwareUpdateState.AwaitingFileSave(firmwareFile, java.io.File(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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,366 @@
|
|||
/*
|
||||
* 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.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
|
||||
import org.meshtastic.core.ble.AndroidBleService
|
||||
import org.meshtastic.core.ble.BleConnectionFactory
|
||||
import org.meshtastic.core.ble.BleConnectionState
|
||||
import org.meshtastic.core.ble.BleDevice
|
||||
import org.meshtastic.core.ble.BleScanner
|
||||
import org.meshtastic.core.ble.BleWriteType
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_NOTIFY_CHARACTERISTIC
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_SERVICE_UUID
|
||||
import org.meshtastic.core.ble.MeshtasticBleConstants.OTA_WRITE_CHARACTERISTIC
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* BLE transport implementation for ESP32 Unified OTA protocol.
|
||||
*
|
||||
* 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 scanner: BleScanner,
|
||||
connectionFactory: BleConnectionFactory,
|
||||
private val address: String,
|
||||
dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
) : UnifiedOtaProtocol {
|
||||
|
||||
private val transportScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
private val bleConnection = connectionFactory.create(transportScope, "BLE OTA")
|
||||
private var otaCharacteristic: RemoteCharacteristic? = null
|
||||
|
||||
private val responseChannel = Channel<String>(Channel.UNLIMITED)
|
||||
|
||||
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. */
|
||||
private suspend fun scanForOtaDevice(): BleDevice? {
|
||||
// ESP32 OTA bootloader may use MAC address with last byte incremented by 1
|
||||
val otaAddress = calculateOtaAddress(macAddress = 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)..." }
|
||||
|
||||
val foundDevices = mutableSetOf<String>()
|
||||
val device =
|
||||
scanner
|
||||
.scan(SCAN_TIMEOUT)
|
||||
.onEach { d ->
|
||||
if (foundDevices.add(d.address)) {
|
||||
Logger.d { "BLE OTA: Scan found device: ${d.address} (name=${d.name})" }
|
||||
}
|
||||
}
|
||||
.firstOrNull { it.address in targetAddresses }
|
||||
|
||||
if (device != null) {
|
||||
Logger.i { "BLE OTA: Found target device at ${device.address}" }
|
||||
return device
|
||||
}
|
||||
|
||||
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..." }
|
||||
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. */
|
||||
@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..." }
|
||||
delay(REBOOT_DELAY_MS)
|
||||
|
||||
Logger.i { "BLE OTA: Connecting to $address using Nordic BLE Library..." }
|
||||
|
||||
val device =
|
||||
scanForOtaDevice()
|
||||
?: throw OtaProtocolException.ConnectionFailed(
|
||||
"Device not found at address $address. " +
|
||||
"Ensure the device has rebooted into OTA mode and is advertising.",
|
||||
)
|
||||
|
||||
bleConnection.connectionState
|
||||
.onEach { state ->
|
||||
Logger.d { "BLE OTA: Connection state changed to $state" }
|
||||
isConnected = state is BleConnectionState.Connected
|
||||
}
|
||||
.launchIn(transportScope)
|
||||
|
||||
try {
|
||||
val finalState = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
|
||||
if (finalState is BleConnectionState.Disconnected) {
|
||||
Logger.w { "BLE OTA: Failed to connect to ${device.address} (state=$finalState)" }
|
||||
throw OtaProtocolException.ConnectionFailed("Failed to connect to device at address ${device.address}")
|
||||
}
|
||||
} catch (@Suppress("SwallowedException") e: kotlinx.coroutines.TimeoutCancellationException) {
|
||||
Logger.w { "BLE OTA: Timed out waiting to connect to ${device.address}. Error: ${e.message}" }
|
||||
throw OtaProtocolException.Timeout("Timed out connecting to device at address ${device.address}")
|
||||
}
|
||||
|
||||
Logger.i { "BLE OTA: Connected to ${device.address}, discovering services..." }
|
||||
|
||||
// Discover services using our unified profile helper
|
||||
bleConnection.profile(OTA_SERVICE_UUID) { service ->
|
||||
val androidService = (service as AndroidBleService).service
|
||||
val ota =
|
||||
requireNotNull(androidService.characteristics.firstOrNull { it.uuid == OTA_WRITE_CHARACTERISTIC }) {
|
||||
"OTA characteristic not found"
|
||||
}
|
||||
val txChar =
|
||||
requireNotNull(androidService.characteristics.firstOrNull { it.uuid == OTA_NOTIFY_CHARACTERISTIC }) {
|
||||
"TX characteristic not found"
|
||||
}
|
||||
|
||||
otaCharacteristic = ota
|
||||
|
||||
// Log negotiated MTU for diagnostics
|
||||
val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE)
|
||||
Logger.i { "BLE OTA: Service ready. Max write value length: $maxLen bytes" }
|
||||
|
||||
// Enable notifications and collect responses
|
||||
val subscribed = CompletableDeferred<Unit>()
|
||||
txChar
|
||||
.subscribe {
|
||||
Logger.d { "BLE OTA: TX characteristic subscribed" }
|
||||
subscribed.complete(Unit)
|
||||
}
|
||||
.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" }
|
||||
}
|
||||
}
|
||||
.catch { e ->
|
||||
if (!subscribed.isCompleted) subscribed.completeExceptionally(e)
|
||||
Logger.e(e) { "BLE OTA: Error in TX characteristic subscription" }
|
||||
}
|
||||
.launchIn(this)
|
||||
|
||||
subscribed.await()
|
||||
Logger.i { "BLE OTA: Service discovered and ready" }
|
||||
}
|
||||
}
|
||||
|
||||
/** Initiates the OTA update by sending the size and hash. */
|
||||
override suspend fun startOta(
|
||||
sizeBytes: Long,
|
||||
sha256Hash: String,
|
||||
onHandshakeStatus: suspend (OtaHandshakeStatus) -> Unit,
|
||||
): Result<Unit> = runCatching {
|
||||
val command = OtaCommand.StartOta(sizeBytes, sha256Hash)
|
||||
val packetsSent = sendCommand(command)
|
||||
|
||||
var handshakeComplete = false
|
||||
var responsesReceived = 0
|
||||
while (!handshakeComplete) {
|
||||
val response = waitForResponse(ERASING_TIMEOUT_MS)
|
||||
responsesReceived++
|
||||
when (val parsed = OtaResponse.parse(response)) {
|
||||
is OtaResponse.Ok -> {
|
||||
if (responsesReceived >= packetsSent) {
|
||||
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Streams the firmware data in chunks. */
|
||||
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
|
||||
val packetsSentForChunk = writeData(chunk, BleWriteType.WITHOUT_RESPONSE)
|
||||
|
||||
// Wait for responses
|
||||
val nextSentBytes = sentBytes + currentChunkSize
|
||||
repeat(packetsSentForChunk) { i ->
|
||||
val response = waitForResponse(ACK_TIMEOUT_MS)
|
||||
val isLastPacketOfChunk = i == packetsSentForChunk - 1
|
||||
|
||||
when (val parsed = OtaResponse.parse(response)) {
|
||||
is OtaResponse.Ack -> {
|
||||
// Normal packet success
|
||||
}
|
||||
|
||||
is OtaResponse.Ok -> {
|
||||
if (nextSentBytes >= totalBytes && isLastPacketOfChunk) {
|
||||
sentBytes = nextSentBytes
|
||||
onProgress(1.0f)
|
||||
return@runCatching Unit
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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() {
|
||||
bleConnection.disconnect()
|
||||
isConnected = false
|
||||
transportScope.cancel()
|
||||
}
|
||||
|
||||
private suspend fun sendCommand(command: OtaCommand): Int {
|
||||
val data = command.toString().toByteArray()
|
||||
return writeData(data, BleWriteType.WITH_RESPONSE)
|
||||
}
|
||||
|
||||
private suspend fun writeData(data: ByteArray, writeType: BleWriteType): Int {
|
||||
val characteristic =
|
||||
otaCharacteristic ?: throw OtaProtocolException.ConnectionFailed("OTA characteristic not available")
|
||||
|
||||
val maxLen = bleConnection.maximumWriteValueLength(writeType) ?: data.size
|
||||
var offset = 0
|
||||
var packetsSent = 0
|
||||
|
||||
try {
|
||||
while (offset < data.size) {
|
||||
val chunkSize = minOf(data.size - offset, maxLen)
|
||||
val packet = data.copyOfRange(offset, offset + chunkSize)
|
||||
|
||||
val nordicWriteType =
|
||||
when (writeType) {
|
||||
BleWriteType.WITH_RESPONSE -> no.nordicsemi.kotlin.ble.core.WriteType.WITH_RESPONSE
|
||||
BleWriteType.WITHOUT_RESPONSE -> no.nordicsemi.kotlin.ble.core.WriteType.WITHOUT_RESPONSE
|
||||
}
|
||||
|
||||
characteristic.write(packet, writeType = nordicWriteType)
|
||||
offset += chunkSize
|
||||
packetsSent++
|
||||
}
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
throw OtaProtocolException.TransferFailed("Failed to write data at offset $offset", e)
|
||||
}
|
||||
return packetsSent
|
||||
}
|
||||
|
||||
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 {
|
||||
// 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
|
||||
private const val ACK_TIMEOUT_MS = 10_000L
|
||||
private const val VERIFICATION_TIMEOUT_MS = 10_000L
|
||||
|
||||
private const val REBOOT_DELAY_MS = 5_000L
|
||||
private const val SCAN_RETRY_COUNT = 3
|
||||
private const val SCAN_RETRY_DELAY_MS = 2_000L
|
||||
|
||||
const val RECOMMENDED_CHUNK_SIZE = 512
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,359 @@
|
|||
/*
|
||||
* 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 co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.ble.BleConnectionFactory
|
||||
import org.meshtastic.core.ble.BleScanner
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.common.util.toPlatformUri
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.firmware_update_connecting_attempt
|
||||
import org.meshtastic.core.resources.firmware_update_downloading_percent
|
||||
import org.meshtastic.core.resources.firmware_update_erasing
|
||||
import org.meshtastic.core.resources.firmware_update_extracting
|
||||
import org.meshtastic.core.resources.firmware_update_hash_rejected
|
||||
import org.meshtastic.core.resources.firmware_update_not_found_in_release
|
||||
import org.meshtastic.core.resources.firmware_update_ota_failed
|
||||
import org.meshtastic.core.resources.firmware_update_starting_ota
|
||||
import org.meshtastic.core.resources.firmware_update_uploading
|
||||
import org.meshtastic.core.resources.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
|
||||
|
||||
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")
|
||||
@Single
|
||||
class Esp32OtaUpdateHandler(
|
||||
private val firmwareRetriever: FirmwareRetriever,
|
||||
private val radioController: RadioController,
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val bleScanner: BleScanner,
|
||||
private val bleConnectionFactory: BleConnectionFactory,
|
||||
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: CommonUri?,
|
||||
): String? = 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: CommonUri? = null,
|
||||
): String? = performUpdate(
|
||||
release = release,
|
||||
hardware = hardware,
|
||||
updateState = updateState,
|
||||
firmwareUri = firmwareUri,
|
||||
transportFactory = { BleOtaTransport(bleScanner, bleConnectionFactory, address) },
|
||||
rebootMode = 1,
|
||||
connectionAttempts = 5,
|
||||
)
|
||||
|
||||
private suspend fun startWifiUpdate(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
deviceIp: String,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
firmwareUri: CommonUri? = null,
|
||||
): String? = 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: CommonUri?,
|
||||
transportFactory: () -> UnifiedOtaProtocol,
|
||||
rebootMode: Int,
|
||||
connectionAttempts: Int,
|
||||
): String? = 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(java.io.File(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
|
||||
}
|
||||
|
||||
@Suppress("UnusedPrivateMember")
|
||||
private suspend fun downloadFirmware(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
): String? {
|
||||
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: CommonUri): String? = withContext(Dispatchers.IO) {
|
||||
val inputStream =
|
||||
context.contentResolver.openInputStream(uri.toPlatformUri() as android.net.Uri)
|
||||
?: return@withContext null
|
||||
val tempFile = java.io.File(context.cacheDir, "firmware_update/ota_firmware.bin")
|
||||
tempFile.parentFile?.mkdirs()
|
||||
inputStream.use { input -> tempFile.outputStream().use { output -> input.copyTo(output) } }
|
||||
tempFile.absolutePath
|
||||
}
|
||||
|
||||
private fun triggerRebootOta(mode: Int, hash: ByteArray?) {
|
||||
val myInfo = nodeRepository.myNodeInfo.value ?: return
|
||||
val myNodeNum = myInfo.myNodeNum
|
||||
Logger.i { "ESP32 OTA: Triggering reboot OTA mode $mode with hash" }
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
radioController.requestRebootOta(radioController.getPacketId(), myNodeNum, mode, hash)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
Logger.i { "ESP32 OTA: Disconnecting mesh service for OTA" }
|
||||
radioController.setDeviceAddress("n")
|
||||
}
|
||||
|
||||
private suspend fun obtainFirmwareFile(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
firmwareUri: CommonUri?,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
): String? {
|
||||
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 if (firmwareUri != null) {
|
||||
val extractingMsg = getString(Res.string.firmware_update_extracting)
|
||||
updateState(FirmwareUpdateState.Processing(ProgressState(message = extractingMsg)))
|
||||
getFirmwareFromUri(firmwareUri)
|
||||
} else {
|
||||
val firmwareFile =
|
||||
firmwareRetriever.retrieveEsp32Firmware(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 {
|
||||
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: String,
|
||||
sha256Hash: String,
|
||||
rebootMode: Int,
|
||||
updateState: (FirmwareUpdateState) -> Unit,
|
||||
) {
|
||||
val file = java.io.File(firmwareFile)
|
||||
// Step 5: Start OTA
|
||||
val startingOtaMsg = getString(Res.string.firmware_update_starting_ota)
|
||||
updateState(FirmwareUpdateState.Processing(ProgressState(startingOtaMsg)))
|
||||
transport
|
||||
.startOta(sizeBytes = file.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 = file.readBytes()
|
||||
val chunkSize =
|
||||
if (rebootMode == 1) {
|
||||
BleOtaTransport.RECOMMENDED_CHUNK_SIZE
|
||||
} else {
|
||||
WifiOtaTransport.RECOMMENDED_CHUNK_SIZE
|
||||
}
|
||||
|
||||
val startTime = nowMillis
|
||||
transport
|
||||
.streamFirmware(
|
||||
data = firmwareData,
|
||||
chunkSize = chunkSize,
|
||||
onProgress = { progress ->
|
||||
val currentTime = nowMillis
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
/*
|
||||
* 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 org.meshtastic.core.common.util.nowMillis
|
||||
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 = nowMillis
|
||||
|
||||
while (nowMillis - 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue